diff --git a/docs/plans/2026-07-05-parallel-redis-runtime-isolation.md b/docs/plans/2026-07-05-parallel-redis-runtime-isolation.md
index 2d8dc7b9b..773988025 100644
--- a/docs/plans/2026-07-05-parallel-redis-runtime-isolation.md
+++ b/docs/plans/2026-07-05-parallel-redis-runtime-isolation.md
@@ -223,7 +223,7 @@ The scoped package-mode run also exposed shared route-file leakage. `defineCache
The scoped run also exposed a Testbench runtime clone cleanup race. Remote Testbench children with the same ParaTest token can bootstrap concurrently and both try to purge the same stale runtime copy left behind by a killed serve child. The shared `Filesystem::deleteDirectory()` behavior stays Laravel-identical; Testbench runtime-copy cleanup handles this local temp-directory race by retrying once and then accepting only the postcondition that the runtime directory is gone. If the directory still exists, the filesystem exception is rethrown.
-The raw ParaTest full suite also exposed an AOP proxy source-map bug. `GenerateProxies` writes generated proxy paths back into Composer's runtime class map. Later app bootstraps then harvested that mutated map as if it still contained source paths. While the generated proxy file still existed, the bug was masked because `ProxyManager::isModified()` compared the proxy file against itself and skipped regeneration. After `cache:clear` / `optimize:clear` deleted `storage/framework/aop`, or after `package:purge-skeleton` purged the same directory from the Testbench runtime clone, a later app bootstrap tried to read the deleted proxy file as source and failed. The fix is for `GenerateProxies` to keep a worker-lifetime source class map, merge Composer's current class map into it on each proxy-generation boot while filtering generated `.proxy.php` paths, and pass explicit source file paths into `Ast` during proxy generation. Exact-rule `findFile()` resolutions must also reject generated proxy paths, because test-only `flushState()` can clear the captured source map while Composer's loader still contains a proxy entry for a previously proxied PSR-4 class. `ProxyManager` must track which source path generated each existing proxy file, because a later class-map override can point a class to a different source file whose mtime is older than the stale proxy. In that case, source-path drift must force regeneration even when the mtime check would otherwise skip it. Do not solve this by restoring Composer's loader from test cleanup; proxy generation should be self-sufficient and should not depend on per-test cleanup mutating the autoloader back into shape.
+The raw ParaTest full suite also exposed an AOP proxy source-map bug. `GenerateProxies` writes generated proxy paths back into Composer's runtime class map. Later app bootstraps then harvested that mutated map as if it still contained source paths. After `cache:clear` / `optimize:clear` deleted `storage/framework/aop`, or after `package:purge-skeleton` purged the same directory from the Testbench runtime clone, a later app bootstrap tried to read the deleted proxy file as source and failed. The fix is for `GenerateProxies` to keep a worker-lifetime source class map, merge Composer's current class map into it on each proxy-generation boot while filtering generated `.proxy.php` paths, and pass explicit source paths and contents into `Ast` during proxy generation. Exact-rule `findFile()` resolutions must also reject generated proxy paths, because test-only `flushState()` can clear the captured source map while Composer's loader still contains a proxy entry for a previously proxied PSR-4 class. `ProxyManager` fingerprints the canonical source path and contents together with every generation input, so source-path changes and same-mtime content changes both invalidate a stale proxy. Do not solve this by restoring Composer's loader from production cleanup; proxy generation must remain self-sufficient. Tests that mutate Composer's loader still restore it exactly so they do not contaminate unrelated tests.
An earlier version of this plan said the full components suite should use `package:test`. That was wrong. Full-suite diagnostics proved the user's original mixed-suite concern was correct:
diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
index 07aa761d8..a28a0d08b 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
@@ -306,3 +306,85 @@ Append package entries in checklist order. Keep each entry compact but complete
- **Laravel-facing result:** Current Laravel contextual attributes and enum manager identifiers are added compatibly. Existing string APIs, configuration, and documented behavior remain unchanged. `buildWith()` expands only Hypervel's own contract to describe behavior the concrete container already provides. Alias, variadic, default-value, lifecycle, and named-logger changes correct invalid behavior without changing supported call shapes.
- **Validation and review:** Every changed Container, Auth, Cache, Log, Routing, and Console test is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, stale-reference scans, a fresh caller/callee and lifecycle review, and independent code review are complete. The final review found no unresolved behavioral, architectural, performance, coroutine-safety, test, or overengineering concern; the only surviving documentation annotation was verified as formatter-owned. The owner approved committing the completed work.
- **Assessment:** The final design fixes demonstrated lifecycle, typing, callback, cache, and concurrent singleton failures at their owning boundaries. Shared-resolution coordination is deliberately limited to contended first construction; it enforces Hypervel's documented one-instance worker contract and prevents provisional callback state from escaping without introducing a general synchronization framework. Normal warmed resolution retains its existing caches and gains only one predictable empty-map branch.
+
+### Correct explicit coroutine context targeting
+
+- **Architecture and inspected risk surfaces:** Context is a Hyperf-derived coroutine-storage package with a Hypervel-owned worker-global fallback and additional copy, bulk-write, flush, and test-transaction bridge APIs. The audit covered every Context source and test file, Engine and high-level Coroutine wrappers, Container scoped-state consumers, Foundation request synchronization, the Database test-transaction bridge, Sentry parent-context reads, public documentation, current local Hyperf source/tests, and verified Swoole 6.2.2 context behavior from coroutine and non-coroutine callers.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `context-01` | Defect | Major | High | Context primitives select storage from the caller's coroutine state instead of the requested coroutine ID, so an outside caller cannot target a live coroutine, invalid explicit writes silently disappear, and `forget()` / `flush()` can clear the wrong worker-global state | Select storage from the requested target: an omitted ID uses the current coroutine or fallback, while any explicit ID addresses only that coroutine; invalid explicit reads return their empty result, mutations are no-ops except `set()` throws `CoroutineDestroyedException`, and target flush clears the selected context in place |
+| `context-02` | Defect | Minor | High | `setMany()` forwards valid integer array keys to a string-or-enum parameter under strict types and throws `TypeError` | Normalize each array key to string before delegating, retaining compatibility with numeric and integer-backed-enum context keys |
+| `context-03` | Defect | Minor | High | Three non-coroutine bridge tests assert inside a high-level child coroutine whose exception wrapper can swallow PHPUnit assertion failures | Capture child results and assert after the blocking coroutine run completes; apply current test typing and metadata conventions to the touched files |
+| `context-04` | Userland footgun | Minor | High | Public bridge writers copy coroutine state into worker-global fallback storage but documentation presents them as ordinary request-time APIs without warning about cross-request races | Mark the two Context writers and their Database transaction wrappers as tests-only worker-global mutations, and frame the public bridge documentation around the testing lifecycle that owns them |
+
+- **Important rejected concerns:** Keep `ParentCoroutineContext` string-only because no consumer needs enum keys and symmetry alone does not justify expanding six public signatures. Keep required Request/Response getters fail-fast, copied non-replicable objects shared as documented, copy and get-or-set operations non-atomic, `CoroutineProxy` behavior unchanged, and copy operations non-transactional on a user-provided `replicate()` failure. Do not add locks, a context registry, special top-level-parent handling, generalized copy rollback, or diagnostic-only wrappers.
+- **Cross-package implications:** `context-01` corrects Container scoped-state rollback and Foundation parent-request synchronization so they no longer over-clear the non-coroutine fallback. `context-04` documents the Database test-transaction bridge's actual ownership. Completed `container-05` and `container-06` behavior was revalidated; later full Foundation and Database audits must retain these boundaries.
+- **Upstream and parity:** Current local Hyperf `Context` source was verified at `examples/hyperf/hyperf/src/context`; it uses the same caller-routed storage selection and unconditional fallback destruction. Hypervel deliberately corrects that inherited bug because its explicit-coroutine-ID API must address the requested live context independently of the caller. Hypervel's `flush()`, `setMany()`, and non-coroutine bridge methods are Hypervel-owned surfaces. Existing Hypervel `copyFrom()` merge semantics remain untouched.
+- **Implementation boundary:** Use direct `Coroutine::getContextFor()` selection rather than a new abstraction. `$coroutineId === null` is the only omitted-target discriminator: omitted selects current-or-fallback, while every explicit non-null value selects only the requested coroutine. Remove the old truthy and negative-ID routing completely. Use `ArrayObject::exchangeArray([])` for an in-place target flush. Add the existing Engine `CoroutineDestroyedException` only at the explicit invalid `set()` boundary.
+- **Implementation:** Every primitive now resolves the requested native context directly. Omitted targets select the current coroutine or fallback, explicit targets never fall back, dead explicit writes throw the existing named exception, and target flushes clear their `ArrayObject` in place. Bulk writes normalize integer PHP array keys before delegation. The non-coroutine Context writers and Database transaction bridge now identify their tests-only worker-global ownership.
+- **Regression tests and documentation:** Deterministically cover outside-coroutine access to a suspended live child, invalid explicit operations, fallback preservation, omitted fallback flush, explicit invalid flush, live-target flush, and integer bulk keys matching integer-backed enums. The three non-coroutine copy tests now assert only after their child coroutine completes, so child failures cannot be swallowed. Public docs describe every dead explicit-ID result and tests-only bridge boundary, and the Context README identifies its Hyperf upstream.
+- **Performance and complexity:** Coroutine context reads and writes remove a redundant native current-CID lookup and add no lock, allocation, registry, retry, or new retained state. Bulk key normalization adds one cast only inside `setMany()`. The fix narrows behavior at invalid explicit targets and otherwise preserves method names, signatures, context-key compatibility, and storage lifetimes.
+- **Laravel-facing result:** No Laravel public API, configuration, documented behavior, or extension convention changes. Context is Hypervel/Hyperf infrastructure; the only observable tightening replaces a silent false-success for an explicitly destroyed coroutine with the existing named Engine exception.
+- **Validation and review:** Focused Context, Container, Foundation request-context synchronization, and Database transaction-manager tests are green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, a fresh caller/callee and lifecycle review, and independent code review are complete. Completed `container-05` and `container-06` assumptions were revalidated against the corrected target-routing contract.
+- **Assessment:** The final design corrects the storage owner at the primitive boundary without adding a helper, registry, lock, retry, or compatibility path. It removes one redundant native call from ordinary coroutine access, preserves the existing API shape, and leaves no caller-routed fallback branch or stale dual-store documentation behind.
+- **Owner pre-commit review:** The owner reviewed the completed work-unit summary and approved committing the signed-off implementation.
+
+### Correct event dispatch, queued-consumer isolation, and queue interoperability
+
+- **Architecture and inspected risk surfaces:** Events is a Laravel-derived dispatcher whose listener registries and prepared-listener caches intentionally have worker lifetime, while deferred and pushed event state is coroutine-local. Hypervel's passive `observe()` channel, guarded `hasListeners()` checks, bare-wildcard observer routing, EventFake visibility, and normal stateless listener auto-singleton reuse are deliberate architecture. The audit covered every Events source and test file; Foundation event dispatch and pending dispatch/chain forwarders; Bus command mapping and queueable traits; Queue listener/handler execution, attributes, locks, and middleware; Broadcasting's queued event wrapper; current Laravel source, tests, documentation, and originating pull-request surfaces; the completed Reflection audit; and every relevant repository caller.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `events-01` | Defect | Minor | High | Foundation event `dispatch()` and `broadcast()` reject named constructor arguments | Port current Laravel's variadic forwarding and named-argument regressions |
+| `events-02` | Defect | Major | High | After-commit observers defer pre-mutation model events, so `false` cannot cancel the mutation and the hook runs after commit | Keep creating, updating, saving, deleting, restoring, and force-deleting listeners synchronous at the callable-selection boundary |
+| `events-03` | Defect | Critical | High | Auto-singletoned queued listeners and mapped command handlers share the mutable framework-injected job handle across concurrent jobs | Shallow-clone only container-resolved consumers using `InteractsWithQueue` before job injection, retain shared dependencies, and leave fresh deserialized commands unchanged |
+| `events-04` | Defect | Minor | High | Queued-closure deduplicators accept callable arrays/objects/strings but reject them through `SerializableClosure`- and `Closure`-only storage/forwarding | Store the exact callable shape, wrap only closures, and make PendingDispatch match Queueable's `array|callable|null` forwarding contract |
+| `events-05` | Defect | Major | High | Queued listeners and broadcast jobs reject array backoff values already supported by methods, attributes, docs, and queue execution | Widen both queued job properties to `array|int|null` and cover method and attribute forms |
+| `events-06` | Defect | Minor | High | Int-backed enum message groups normalize to integers that QueuedClosure and PendingDispatch reject | Retain `int|string|null` in QueuedClosure and make PendingDispatch match Queueable's complete group input contract |
+| `events-07` | Defect | Minor | High | Queued enum events fail at two unconditional object-clone boundaries because enum cases are immutable but uncloneable | Preserve `UnitEnum` cases at the two exact clone boundaries without adding a general cloneability framework |
+| `events-08` | Defect | Minor | High | Four coroutine-event regressions can strand a WaitGroup and hang when a child assertion or dispatch throws | Use the repository `parallel()` primitive so child failures propagate and all children are joined |
+| `events-09` | Defect | Minor | High | The legacy string listener-data compatibility path is unused by current producers and contradicts the job's unconditional array cloning | Make listener data array-only, remove the obsolete prepare path, and record the intentional current-Laravel difference in source and README |
+| `events-10` | Defect | Minor | High | Events lacks consumer coverage for the corrected first-closure-parameter inference contract from `reflection-01` | Prove an invalid first parameter is rejected and a valid first union registers every event type |
+| `events-11` | Defect | Minor | High | Events package provenance and dispatcher listener-list PHPDocs are incomplete or inaccurate | Add the Laravel upstream reference and correct only verified local type descriptions |
+| `queue-11` | Defect | Minor | High | Hypervel's Backoff attribute silently drops all but the first argument from current Laravel's variadic form | Port the current variadic constructor, normalizing the variadic array with `array_values()` before scalar-versus-sequence selection so the prior named-argument forms (`#[Backoff(backoff: ...)]`) survive PHP's variadic name preservation alongside the positional scalar, array, and variadic forms; document each form |
+| `queue-12` | Defect | Major | High | Custom display-name unique and overlap lock keys differ from current Laravel, breaking mixed-framework acquisition and release | Hash custom display names with `xxh128` at both current Laravel key boundaries and pin exact interoperability keys |
+| `foundation-01` | Defect | Minor | High | Real and fake PendingChain dispatch forwarders reject named first-job constructor arguments | Make both load-compatible overrides variadic and forward the captured arguments; leave `dispatchIf()` and `dispatchUnless()` unchanged |
+
+- **Important rejected concerns:** Do not add registry synchronization, request-local listener registries, static cleanup entries, general transient-container construction, deep dependency cloning, a mutex, context slot, state machine, general uncloneable-object handling, unique-lock cleanup after an ambiguous queue-push exception, reflection-cache routing, or cosmetic strictness churn. Preserve `hasListeners()`, `observe()`, bare-`*` passive routing, interface/wildcard listener caches, EventFake semantics, Telescope consumers, and normal stateless listener auto-singletons. `dispatchIf()` and `dispatchUnless()` have never forwarded first-job constructor arguments and remain unchanged.
+- **Approved implementation boundary:** The owner approved every measured correctness cost: approximately 27 nanoseconds only when classifying an already-after-commit listener; approximately 33 nanoseconds only when isolating a queued consumer that uses `InteractsWithQueue`; approximately 8.7 nanoseconds at each queued enum clone boundary; and approximately 99 nanoseconds only when generating a custom display-name lock key before cache I/O. The owner also approved the additive `PendingChain::dispatch(mixed ...$arguments)` signature divergence from current Laravel's still-defective non-variadic method. Both the real and fake overrides must change together to avoid a class-load fatal. Event Dispatchable variadics, after-commit cancellation, Backoff variadics, and lock hashing catch up to current Laravel; PendingChain advances a verified upstream bug fix. The Backoff variadic key normalization additionally corrects a named-argument regression present in current Laravel's own variadic constructor; that fix and the queued enum bug are both shared with current Laravel and should be proposed upstream.
+- **Cross-package implications:** Events owns listener dispatch, queued-listener construction, and the exact enum clone decisions. Queue owns mapped-handler job injection, Backoff, and lock generation. Broadcasting owns its truthful backoff property. Foundation owns event and pending-dispatch forwarding plus the real PendingChain; Support owns the matching fake. Later full Queue, Broadcasting, Foundation, and Support audits must retain and revalidate these boundaries.
+- **Regression strategy:** Port current upstream named event dispatch/broadcast and after-commit cancellation coverage; deterministically interleave two queued listeners and two mapped handlers to prove per-job handle isolation; cover Closure and non-Closure deduplicators through dispatch; cover scalar, array, named-argument scalar and array, method, and variadic-attribute backoffs across listeners and broadcasts; cover int-backed groups through PendingDispatch; queue and serialize enum events; replace hanging coroutine test orchestration with `parallel()`; preserve valid array listener cloning/handling; pin corrected reflection-listener inference; assert exact current Laravel unique/overlap keys; and exercise named constructor arguments through both real and fake PendingChain dispatch.
+- **Implementation:** Event and PendingChain forwarders now preserve named constructor arguments; pre-mutation after-commit observers remain synchronous; job-aware container-resolved queued listeners and mapped handlers are cloned before per-execution job injection; enum cases survive queued-event cloning; callable deduplicators, integer message groups, array backoff, and current Laravel lock keys flow through their complete producer/consumer chains. Backoff accepts positional scalar, positional array, positional variadic, named scalar, and named array forms. Obsolete serialized-string listener payload handling is removed, coroutine regressions use `parallel()`, and provenance, API docs, and the intentional Laravel difference are current.
+- **Regression tests:** Focused coverage deterministically reproduces queued-listener and mapped-handler job-handle races, proves after-commit cancellation and timing, exercises named dispatch/broadcast and real/fake chain construction, preserves enum queue serialization, and covers every accepted group, deduplicator, backoff, and lock-key shape. Reflection's corrected first-parameter inference is pinned at the Events consumer boundary, and coroutine-event tests now propagate child failures without strandable wait-group bookkeeping.
+- **Performance and complexity:** Ordinary synchronous dispatch, listener lookup, passive observation, and warmed registry reads are unchanged. The accepted nanosecond-scale work is confined to the exact correctness paths above and is negligible beside queued serialization, dispatch, or cache I/O. Test orchestration and legacy payload handling become smaller. No lock, retry, registry, generalized cloning abstraction, or retained worker state is added.
+- **Laravel-facing result:** Existing Laravel event registration, dispatch, queue, broadcasting, and chaining call shapes remain valid. Most changes restore current Laravel behavior or widen Hypervel's over-narrow forwarding types. PendingChain intentionally adds named constructor arguments ahead of current Laravel with owner approval. The internal queued-listener payload drops only a pre-2017 string compatibility form that current producers never emit and that the existing clone path cannot process.
+- **Validation and review:** Every changed test file and the complete affected Events, Queue, Bus, Broadcasting, Foundation, Support, and Database groups are green. PHP CS Fixer, both PHPStan configurations, the full parallel suite, both Testbench suites, `git diff --check`, stale-reference and caller scans, fresh self-review, and independent code review are complete. The final review found one listener-spec PHPDoc narrowing error; the corrected annotations retain raw `Class@method` strings and callable support, and both PHPStan configurations remain green.
+- **Assessment:** The final design fixes demonstrated dispatch, transaction timing, shared-instance mutation, queue typing, serialization, interoperability, and test-liveness defects at their owning boundaries. It adds no worker state or generalized concurrency machinery, removes obsolete code, confines small costs to queued or custom-key paths, and leaves the ordinary event hot path unchanged.
+
+### Correct AOP proxy generation and publication
+
+- **Architecture and inspected risk surfaces:** DI owns boot-time Composer class-map overrides and generated AOP proxies. Runtime advice resolution is worker-cached by class and method, while each intercepted invocation receives a fresh mutable pipeline. The audit covered every DI source file and test, Foundation's AOP testing concern, Support aspect registration, Sentry and Telescope consumers, Composer loader mutation, Filesystem generation boundaries, current Hyperf source/tests, the completed Reflection metadata work, generated PHP language semantics, and the complete repository caller surface.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `di-01` | Defect | Major | High | Moving a method body into a new closure on every invocation breaks method-local statics, argument-introspection functions, references, `never`, magic constants, nested closure descriptors, and default-object identity | Keep the original signature as a wrapper, move its body to one private collision-checked helper, and rewrite only the method-lexical constructs needed to preserve native PHP behavior |
+| `di-02` | Defect | Major | High | Injected `ProxyTrait` dispatch methods can collide with target methods and duplicate the Foundation test helper's runtime pipeline logic | Use one stateless `ProxyDispatcher` for generated and manual calls, and identify generated classes, enums, and traits through one empty shared `ProxyMarker` trait |
+| `di-03` | Defect | Major | High | Source mtime alone reuses stale proxies after aspect, visitor, generator, dependency, source-path, or same-mtime source changes | Embed and validate a complete content fingerprint before reusing an artifact |
+| `di-04` | Defect | Major | High | Proxy filenames collide, native reads and directory creation are unchecked, and direct writes can publish partial PHP files | Use encoded class filenames, one authoritative source path, checked reads and directory creation, and atomic replacement |
+| `di-05` | Defect | Minor | High | Class-map tests add fake entries to Composer's real worker loader even though metadata cleanup cannot remove them | Swap in a registered isolated loader for each mutating test and restore the original loader exactly |
+| `filesystem-02` | Defect | Minor | High | `Filesystem::ensureDirectoryExists()` ignores a failed creation and returns without establishing its named postcondition | Use the existing forced creation boundary, accept a concurrent creator through a second directory observation, and otherwise throw deterministically |
+
+- **Important rejected concerns:** Do not add locks around no-yield aspect resolution, dynamic runtime aspect or visitor registries, per-request aspect state, a compatibility dispatcher trait, unconditional proxy parsing, a manual cache-version constant, or grouped-source proxy generation without a real consumer. Files containing multiple named class-likes are rejected before publication; implementing complete support would require one artifact and loader mapping for every named declaration in the source. Nested anonymous classes remain supported. Reference-returning advice is rejected because the existing value-returning aspect protocol cannot preserve arbitrary reference identity safely.
+- **Approved implementation boundary:** The owner approved replacing the inherited Hyperf closure generator with the wrapper/helper design after reviewing the reproduced failures, upstream-maintenance cost, and overengineering boundary. The owner also approved `filesystem-02`, including the Laravel-facing change from silent directory-creation failure to a deterministic exception. Laravel has no native AOP API; Hypervel's documented aspect registration, target declarations, `ProceedingJoinPoint`, advice pipeline, and configuration remain unchanged. The complete intercepted hot path must be benchmarked, and any measured regression returns to the owner stop gate before implementation continues.
+- **Generation and runtime design:** Preserve original method signatures, attributes, documentation, modifiers, defaults, return types, lexical magic constants, PHP 8.4 closure descriptors, and top-level argument-introspection behavior. Resolve direct argument-introspection calls through PHP's function-import and runtime namespace-fallback rules; reject literal indirect `call_user_func` forms and unpacked argument-introspection calls that PHP can bind to the caller frame rather than silently reporting the helper frame. Forward reference parameters and variadics by reference so advice and the original body mutate the caller's values. Use expression dispatch for `void` and `never`. Compute the complete aspect list locally and publish it to `AspectManager` once. Inject an intentionally empty `ProxyMarker` trait and detect its exact identity through the existing recursive trait helper, including proxied traits and inherited proxies, without reserving a target method name. Leave unstable anonymous-class runtime identities native rather than rejecting useful source or emulating process-dependent names.
+- **Artifact design:** Fingerprint the canonical source path and content, complete aspect rules, visitor order and implementation content, the DI AOP generator source, installed `nikic/php-parser` identity, and `PHP_VERSION_ID`. Read only the embedded header on a hit. On a miss, generate to a collision-free encoded path and publish with `Filesystem::replace()`. Reject an invalid target before writing that target's proxy or publishing any loader-map entry; a rejection anywhere in the batch publishes no map. Valid artifacts written earlier in a failed batch remain safe fingerprinted cache entries for a later boot. Document the supported one-named-class-like source shape in the AOP guide.
+- **Cleanup and cross-package implications:** Remove dead `Ast::parseClassByStmts()`, `ProxyManager::getAspectClasses()`, the dispatching `ProxyTrait`, and their obsolete fixtures/tests; retain `RewriteCollection::getShouldNotRewriteMethods()` and the test-owned `AspectCollector::forgetAspect()`. Route `ServiceProvider::aspects()` through `ClassMetadataCache::reflectClass()` to complete `reflection-04`. The later full `filesystem`, `foundation`, `support`, `sentry`, and `telescope` audits must retain and revalidate these boundaries.
+- **Regression strategy:** Compare proxied and unproxied execution for omitted, positional, named-skipped, numeric-variadic, and named-variadic argument shapes. Cover global, imported, namespace-shadowed, and explicit-relative argument-introspection calls; valid named `func_get_arg`; unchanged first-class callables; and deterministic rejection of literal indirect and builtin-resolving unpacked calls while definite custom functions remain untouched. Cover method-local statics, default-object identity, caller-visible mutation by original bodies and aspects, reference variadics, nested magic constants, private/static/trait/aliased/void/never methods, deterministic pre-publication rejection, fingerprint inputs, same-mtime changes, encoded-name collisions, failed native boundaries, atomic publication, exact marker detection, loader restoration, and real Sentry/Telescope interception.
+- **Implementation:** Generated proxies now retain each original method signature as the advice wrapper and move its body to one collision-checked private helper. One stateless dispatcher owns generated and manual aspect calls, and one empty marker trait identifies proxied classes, enums, traits, aliases, inherited proxies, and classes composing multiple proxied traits without reserving a method name. Direct argument-introspection calls preserve the original frame across positional, named, skipped, and variadic calls; unsafe indirect or unpacked forms fail before publication. Magic constants, nested PHP 8.4 closure descriptors, method-local statics, default-object identity, references, `void`, and `never` retain native behavior. Proxy artifacts use collision-free encoded paths, checked native boundaries, complete content fingerprints, header-only cache hits, atomic replacement, and all-or-nothing loader-map publication. Class-map tests isolate and restore their Composer loader, Support uses the canonical reflection cache, and failed directory creation now throws after allowing a concurrent creator.
+- **Regression tests:** Native and proxied twins cover every supported argument shape and both native `func_get_arg()` failure messages. Focused coverage also pins function-import and namespace fallback rules, rejected dynamic forms, method-local state, object defaults, caller-visible references, variadics, private/static/trait/aliased/enum/void/never methods, exact and inherited marker detection, multiple proxied traits, nested closure-descriptor format, leading-backslash normalization, generated-name collisions, source-shape rejection, every fingerprint input, same-mtime changes, encoded filename collisions, native boundary failures, batch publication, loader restoration, and real Sentry and Telescope interception.
+- **Performance and complexity:** Proxy cache hits perform boot-only content hashing and a header read instead of unconditional parsing. Seven alternating warmed benchmark rounds of the complete intercepted path, with 400,000 calls per round, measured the old design at a 2,679.1-nanosecond median and the new design at 2,474.6 nanoseconds, a 7.63% improvement. Request-time dispatch removes the old `__proxyCall()` to `handleAround()` layer while adding the private helper call. No mutex, coroutine context, retry loop, runtime registry, or compatibility path was added; compiler complexity exists only where native PHP behavior must be preserved.
+- **Laravel-facing result:** Laravel has no native AOP API. Hypervel's documented aspect registration, target declarations, join point, advice pipeline, and configuration remain unchanged. Unsupported source shapes now fail clearly before publication. The owner-approved Filesystem change corrects silent directory-creation failure to the method's existing exception contract without changing supported call shapes.
+- **Validation and review:** Focused DI, Filesystem, Foundation AOP, Support, Sentry, and Telescope tests are green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, stale-reference scans, package-checklist parity, full fresh caller/callee and generated-code review, and independent code review are complete. The final review verified native PHP 8.4 behavior, proxy fingerprints and publication, marker composition, loader isolation, filesystem boundaries, and the deliberate rejection of method-local named class declarations.
+- **Assessment:** The final design fixes demonstrated semantic, cache-validity, publication, filesystem, and test-isolation defects at their owning boundaries. It deletes the old duplicate dispatch path and dead metadata, improves the measured intercepted hot path, and adds no speculative synchronization, compatibility layer, or general source-transformation framework.
+- **Owner pre-commit review:** The owner reviewed the completed work-unit summary and approved committing the signed-off implementation.
diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
index dc8c4558b..3fd308c47 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
@@ -181,6 +181,10 @@ The following behavior was reproduced on Swoole 6.2.2 while fixing the test-suit
- `Coroutine::yield()` parks the coroutine until another path explicitly resumes it. It is not a substitute for a scheduling yield such as the production runtime's hooked `usleep(0)`.
- Native Swoole handles can already be torn down when PHP destructors run. Native channel/resource closure therefore belongs to an explicit deterministic lifecycle path, not `__destruct()`. A destructor may perform only PHP-local best-effort cleanup whose full call chain is proven not to reach a native handle.
+### Verified PHP compiler behavior
+
+PHP 8.4 changed `__FUNCTION__` and `__METHOD__` inside closures and arrow functions from the historical bare `{closure}` value to a descriptor containing the lexical parent and closure definition line, such as `{closure:App\Service::method():42}`. Nested closures compose that descriptor. A source compiler that moves a method body must preserve the original lexical name and line rather than exposing its generated helper. Revalidate this descriptor format after every PHP major or minor upgrade; a native-runtime canary must fail if PHP changes the format assumed by the generator.
+
## Audit principles
### 1. Verify before changing
@@ -986,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w
This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md).
-- **Active package or work unit:** `context`
-- **Ledger entries required for the active work:** `Coordinate shared container construction and complete current contextual resolution` (`container-05`, `container-06`)
-- **Pending revalidation carried into the active work:** `container-05`, `container-06`
+- **Active package or work unit:** `log`
+- **Ledger entries required for the active work:** `Coordinate shared container construction and complete current contextual resolution` (`container-09`, `container-10`)
+- **Pending revalidation carried into the active work:** `container-09`, `container-10`
Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread.
@@ -1005,16 +1009,28 @@ Add one row only for a shared finding or changed lower-level assumption that ano
| `testbench-01` | `testbench` | `foundation`; later full `testbench` and `foundation` audits | `Restore Conditionable proxy truthiness`; shared finding `testbench-01` |
| `http-01` | `http` | `macroable`, `testing`; later full `http` and `testing` audits | `Complete Macroable callable and test-state handling`; shared finding `http-01` |
| `console-01` | `console` | `contracts`; later full `console` audit | `Preserve typed console contracts during Composer scripts`; shared finding `console-01` |
-| `reflection-01` | `reflection` | `events`, `foundation`; later full `events` and `foundation` audits | `Consolidate reflection metadata and correct callable inference`; finding `reflection-01` |
+| `reflection-01` | `reflection` | `events` (revalidation complete), `foundation`; later full `foundation` audit | `Consolidate reflection metadata and correct callable inference`; finding `reflection-01` |
| `reflection-02` | `reflection` | `console`, `routing`, `view`, `foundation`; later full consumer audits | `Consolidate reflection metadata and correct callable inference`; finding `reflection-02` |
-| `reflection-04` | `reflection` | `di`, `support`, `queue`, `testing`; later full consumer audits | `Consolidate reflection metadata and correct callable inference`; finding `reflection-04` |
+| `reflection-04` | `reflection` | `di` (revalidation complete), `support`, `queue`, `testing`; later full consumer audits | `Consolidate reflection metadata and correct callable inference`; finding `reflection-04` |
| `config-01` | `config` | `foundation`; later full `foundation` audit | `Preserve configuration identity across worker reloads`; finding `config-01` |
| `config-02` | `foundation` | `testing`, `reverb`; later full consumer audits | `Preserve configuration identity across worker reloads`; finding `config-02` |
-| `container-05` | `container` | `context`; later full `context` audit | `Coordinate shared container construction and complete current contextual resolution`; finding `container-05` |
-| `container-06` | `container` | `context`; later full `context` audit | `Coordinate shared container construction and complete current contextual resolution`; finding `container-06` |
+| `container-05` | `container` | `context` (revalidation complete) | `Coordinate shared container construction and complete current contextual resolution`; finding `container-05` |
+| `container-06` | `container` | `context` (revalidation complete) | `Coordinate shared container construction and complete current contextual resolution`; finding `container-06` |
| `container-08` | `container` | `auth`, `cache`, `log`, `routing`, `support`; later full consumer audits | `Coordinate shared container construction and complete current contextual resolution`; finding `container-08` |
| `container-09` | `auth`, `cache`, `log` | `container` (revalidation complete); later full `auth`, `cache`, and `log` audits | `Coordinate shared container construction and complete current contextual resolution`; finding `container-09` |
| `container-10` | `log` | `container` (revalidation complete); later full `log` audit | `Coordinate shared container construction and complete current contextual resolution`; finding `container-10` |
+| `context-01` | `context` | `container` (revalidation complete), `foundation`; later full `foundation` audit | `Correct explicit coroutine context targeting`; finding `context-01` |
+| `context-04` | `context` | `foundation`, `database`; later full consumer audits | `Correct explicit coroutine context targeting`; finding `context-04` |
+| `di-02` | `di` | `foundation`, `sentry`, `telescope`; later full consumer audits | `Correct AOP proxy generation and publication`; finding `di-02` |
+| `filesystem-02` | `filesystem` | `di` (revalidation complete); later full `filesystem` audit | `Correct AOP proxy generation and publication`; finding `filesystem-02` |
+| `events-01` | `foundation` | `events` (revalidation complete); later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-01` |
+| `events-03` | `events`, `queue` | later full `queue` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-03` |
+| `events-04` | `events`, `foundation` | later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-04` |
+| `events-05` | `events`, `broadcasting` | later full `broadcasting` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-05` |
+| `events-06` | `events`, `foundation` | later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-06` |
+| `queue-11` | `queue` | `events` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` |
+| `queue-12` | `bus`, `queue` | `events` (revalidation complete), `broadcasting`; later full `bus`, `queue`, and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` |
+| `foundation-01` | `foundation` | `support`; later full `foundation` and `support` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` |
## Package checklist
@@ -1044,9 +1060,9 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen
- [x] `reflection`
- [x] `config`
- [x] `container`
-- [ ] `context`
-- [ ] `di`
-- [ ] `events`
+- [x] `context`
+- [x] `di`
+- [x] `events`
- [ ] `log`
- [ ] `support`
diff --git a/src/boost/docs/aop.md b/src/boost/docs/aop.md
index 077ab8e78..883325fd2 100644
--- a/src/boost/docs/aop.md
+++ b/src/boost/docs/aop.md
@@ -224,7 +224,9 @@ $this->aspects(ProfileReports::class, TraceHttpRequests::class);
Hypervel generates AOP proxy classes automatically during application bootstrap. Generated proxies are written to the `storage/framework/aop` directory.
-If no aspects have been registered, proxy generation does nothing. When a proxy file already exists, Hypervel compares the proxy file modification time against the original class file and regenerates the proxy when the original class is newer.
+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.
+
+Each targeted source file must declare exactly one named class, interface, trait, or enum. Nested anonymous classes are supported, but multiple named class-like declarations should be split into separate files before applying an aspect. Methods that return by reference cannot be intercepted because the aspect pipeline returns values rather than reference identities.
The `cache:clear` Artisan command removes generated AOP proxies:
diff --git a/src/boost/docs/coroutine-context.md b/src/boost/docs/coroutine-context.md
index 9b4b90195..f76a80868 100644
--- a/src/boost/docs/coroutine-context.md
+++ b/src/boost/docs/coroutine-context.md
@@ -81,6 +81,8 @@ You may pass a coroutine ID as the third argument to store a value in a specific
CoroutineContext::set('tenant_id', 123, $coroutineId);
```
+An explicit coroutine ID always targets only that coroutine, regardless of whether the caller is running inside another coroutine. If the requested coroutine does not exist, `set` throws a `Hypervel\Engine\Exceptions\CoroutineDestroyedException`. In the same situation, `get` returns its default, `has` returns `false`, `forget` and `flush` do nothing, and `getContainer` returns `null`. These operations never fall back to the shared non-coroutine context store when an explicit ID is supplied.
+
### Determining Item Existence
@@ -300,31 +302,16 @@ CoroutineContext::copyFromNonCoroutine(['request_id'], $coroutineId);
### Copying To Non-Coroutine Context
-The `copyToNonCoroutine` method copies values from a coroutine context into the non-coroutine context store:
+Hypervel's test infrastructure uses the `copyToNonCoroutine` method to bridge selected state from a test coroutine into PHPUnit lifecycle code that runs outside a coroutine. You may select specific keys and, when necessary, the source coroutine:
```php
use Hypervel\Context\CoroutineContext;
-use function Hypervel\Coroutine\run;
-
-run(function () {
- CoroutineContext::set('request_id', 'abc');
-
- CoroutineContext::copyToNonCoroutine();
-});
+CoroutineContext::copyToNonCoroutine(['test_state'], $coroutineId);
```
-You may copy only specific keys:
-
-```php
-CoroutineContext::copyToNonCoroutine(['request_id']);
-```
-
-You may pass a coroutine ID as the second argument to copy from a specific coroutine:
-
-```php
-CoroutineContext::copyToNonCoroutine(['request_id'], $coroutineId);
-```
+> [!WARNING]
+> `copyToNonCoroutine` writes to process-global storage shared by every coroutine in the worker. It is intended only for controlled test lifecycle bridges and must not be used to propagate request state.
### Reading Non-Coroutine Context
@@ -337,12 +324,15 @@ use Hypervel\Context\CoroutineContext;
$requestId = CoroutineContext::getFromNonCoroutine('request_id');
```
-You may clear specific keys from the non-coroutine context store using `clearFromNonCoroutine`:
+Test infrastructure may clear its owned keys from the non-coroutine context store using `clearFromNonCoroutine`:
```php
-CoroutineContext::clearFromNonCoroutine(['request_id']);
+CoroutineContext::clearFromNonCoroutine(['test_state']);
```
+> [!WARNING]
+> `clearFromNonCoroutine` mutates process-global storage and is intended only for controlled test lifecycle cleanup.
+
### Replicable Context Values
diff --git a/src/boost/docs/events.md b/src/boost/docs/events.md
index e0936daf1..1ca099c4b 100644
--- a/src/boost/docs/events.md
+++ b/src/boost/docs/events.md
@@ -791,7 +791,7 @@ class SendShipmentNotification implements ShouldQueue
}
```
-If you require more complex logic for determining the listeners's backoff time, you may define a `backoff` method on your listener class:
+If you require more complex logic for determining the listener's backoff time, you may define a `backoff` method on your listener class:
```php
/**
@@ -817,6 +817,8 @@ public function backoff(OrderShipped $event): array
}
```
+You may also declare the sequence directly on the listener using either `#[Backoff([1, 5, 10])]` or `#[Backoff(1, 5, 10)]`.
+
#### Specifying Queued Listener Max Exceptions
diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md
index c191c5ee0..8690e980d 100644
--- a/src/boost/docs/queues.md
+++ b/src/boost/docs/queues.md
@@ -2805,6 +2805,8 @@ class ProcessPodcast implements ShouldQueue
}
```
+The `Backoff` attribute also accepts the same sequence as separate arguments: `#[Backoff(1, 5, 10)]`.
+
### Cleaning Up After Failed Jobs
diff --git a/src/broadcasting/src/BroadcastEvent.php b/src/broadcasting/src/BroadcastEvent.php
index dffa79ace..0f0457644 100644
--- a/src/broadcasting/src/BroadcastEvent.php
+++ b/src/broadcasting/src/BroadcastEvent.php
@@ -42,7 +42,7 @@ class BroadcastEvent implements ShouldQueue
/**
* The number of seconds to wait before retrying the job when encountering an uncaught exception.
*/
- public ?int $backoff;
+ public array|int|null $backoff;
/**
* The maximum number of unhandled exceptions to allow before failing.
diff --git a/src/bus/src/UniqueLock.php b/src/bus/src/UniqueLock.php
index 64b634815..e96811d2b 100644
--- a/src/bus/src/UniqueLock.php
+++ b/src/bus/src/UniqueLock.php
@@ -58,7 +58,7 @@ public static function getKey(mixed $job): string
: ($job->uniqueId ?? '');
$jobName = method_exists($job, 'displayName')
- ? $job->displayName()
+ ? hash('xxh128', $job->displayName())
: get_class($job);
// IMPORTANT: Uses Laravel's prefix for cross-framework queue interoperability.
diff --git a/src/context/README.md b/src/context/README.md
index 0ced38474..8e7b6781e 100644
--- a/src/context/README.md
+++ b/src/context/README.md
@@ -2,3 +2,5 @@ Context for Hypervel
===
[](https://deepwiki.com/hypervel/context)
+
+Ported from: https://github.com/hyperf/context
diff --git a/src/context/src/CoroutineContext.php b/src/context/src/CoroutineContext.php
index 0058ecf33..c112d6478 100644
--- a/src/context/src/CoroutineContext.php
+++ b/src/context/src/CoroutineContext.php
@@ -7,6 +7,7 @@
use ArrayObject;
use Closure;
use Hypervel\Engine\Coroutine;
+use Hypervel\Engine\Exceptions\CoroutineDestroyedException;
use UnitEnum;
use function Hypervel\Support\enum_value;
@@ -26,15 +27,20 @@ class CoroutineContext
* @param TKey $id
* @param TValue $value
* @return TValue
+ *
+ * @throws CoroutineDestroyedException
*/
public static function set(UnitEnum|string $id, mixed $value, ?int $coroutineId = null): mixed
{
$id = enum_value($id);
+ $context = Coroutine::getContextFor($coroutineId);
- if (Coroutine::id() > 0) {
- Coroutine::getContextFor($coroutineId)[$id] = $value;
- } else {
+ if ($context !== null) {
+ $context[$id] = $value;
+ } elseif ($coroutineId === null) {
static::$nonCoroutineContext[$id] = $value;
+ } else {
+ throw new CoroutineDestroyedException(sprintf('Coroutine #%d has been destroyed.', $coroutineId));
}
return $value;
@@ -49,12 +55,15 @@ public static function set(UnitEnum|string $id, mixed $value, ?int $coroutineId
public static function get(UnitEnum|string $id, mixed $default = null, ?int $coroutineId = null): mixed
{
$id = enum_value($id);
+ $context = Coroutine::getContextFor($coroutineId);
- if (Coroutine::id() > 0) {
- return Coroutine::getContextFor($coroutineId)[$id] ?? $default;
+ if ($context !== null) {
+ return $context[$id] ?? $default;
}
- return static::$nonCoroutineContext[$id] ?? $default;
+ return $coroutineId === null
+ ? static::$nonCoroutineContext[$id] ?? $default
+ : $default;
}
/**
@@ -65,12 +74,13 @@ public static function get(UnitEnum|string $id, mixed $default = null, ?int $cor
public static function has(UnitEnum|string $id, ?int $coroutineId = null): bool
{
$id = enum_value($id);
+ $context = Coroutine::getContextFor($coroutineId);
- if (Coroutine::id() > 0) {
- return isset(Coroutine::getContextFor($coroutineId)[$id]);
+ if ($context !== null) {
+ return isset($context[$id]);
}
- return isset(static::$nonCoroutineContext[$id]);
+ return $coroutineId === null && isset(static::$nonCoroutineContext[$id]);
}
/**
@@ -81,12 +91,13 @@ public static function has(UnitEnum|string $id, ?int $coroutineId = null): bool
public static function forget(UnitEnum|string $id, ?int $coroutineId = null): void
{
$id = enum_value($id);
+ $context = Coroutine::getContextFor($coroutineId);
- if (Coroutine::id() > 0) {
- unset(Coroutine::getContextFor($coroutineId)[$id]);
+ if ($context !== null) {
+ unset($context[$id]);
+ } elseif ($coroutineId === null) {
+ unset(static::$nonCoroutineContext[$id]);
}
-
- unset(static::$nonCoroutineContext[$id]);
}
/**
@@ -162,7 +173,7 @@ public static function getOrSet(UnitEnum|string $id, mixed $value, ?int $corouti
public static function setMany(array $values, ?int $coroutineId = null): void
{
foreach ($values as $key => $value) {
- static::set($key, $value, $coroutineId);
+ static::set((string) $key, $value, $coroutineId);
}
}
@@ -194,6 +205,9 @@ public static function copyFromNonCoroutine(array $keys = [], ?int $coroutineId
/**
* Copy context data from the specified coroutine context to non-coroutine context.
+ *
+ * Tests only. This copies coroutine-local values into worker-global storage,
+ * so request-time use can leak or race across concurrent requests.
*/
public static function copyToNonCoroutine(array $keys = [], ?int $coroutineId = null): void
{
@@ -234,8 +248,8 @@ public static function getFromNonCoroutine(UnitEnum|string $id, mixed $default =
/**
* Clear specific keys from non-coroutine context only.
*
- * Unlike forget() which clears from both contexts, this only affects
- * non-coroutine storage. Useful for clearing stale data before copying.
+ * Tests only. This mutates worker-global storage, so request-time use can
+ * race with concurrent requests using the same keys.
*/
public static function clearFromNonCoroutine(array $keys): void
{
@@ -249,25 +263,12 @@ public static function clearFromNonCoroutine(array $keys): void
*/
public static function flush(?int $coroutineId = null): void
{
- $coroutineId = $coroutineId ?: Coroutine::id();
+ $context = Coroutine::getContextFor($coroutineId);
- // Clear non-coroutine context in non-coroutine environment.
- if ($coroutineId < 0) {
+ if ($context !== null) {
+ $context->exchangeArray([]);
+ } elseif ($coroutineId === null) {
static::$nonCoroutineContext = [];
- return;
- }
-
- if (! $context = Coroutine::getContextFor($coroutineId)) {
- return;
- }
-
- $contextKeys = [];
- foreach ($context as $key => $_) {
- $contextKeys[] = $key;
- }
-
- foreach ($contextKeys as $key) {
- static::forget($key, $coroutineId);
}
}
@@ -278,10 +279,12 @@ public static function flush(?int $coroutineId = null): void
*/
public static function getContainer(?int $coroutineId = null): array|ArrayObject|null
{
- if (Coroutine::id() > 0) {
- return Coroutine::getContextFor($coroutineId);
+ $context = Coroutine::getContextFor($coroutineId);
+
+ if ($context !== null) {
+ return $context;
}
- return static::$nonCoroutineContext;
+ return $coroutineId === null ? static::$nonCoroutineContext : null;
}
}
diff --git a/src/database/src/DatabaseTransactionsManager.php b/src/database/src/DatabaseTransactionsManager.php
index 0ecccbc7c..80a7231b0 100755
--- a/src/database/src/DatabaseTransactionsManager.php
+++ b/src/database/src/DatabaseTransactionsManager.php
@@ -316,6 +316,9 @@ public static function hasNonCoroutinePendingTransactions(): bool
/**
* Copy transaction state from the current coroutine to non-coroutine storage.
+ *
+ * Tests only. This copies coroutine-local transaction state into worker-global
+ * storage, so request-time use can leak or race across concurrent requests.
*/
public static function copyToNonCoroutineState(): void
{
@@ -328,6 +331,9 @@ public static function copyToNonCoroutineState(): void
/**
* Clear all transaction state from non-coroutine storage.
+ *
+ * Tests only. This mutates worker-global transaction state, so request-time
+ * use can race with concurrent requests using the same storage.
*/
public static function clearNonCoroutineState(): void
{
diff --git a/src/di/src/Aop/AspectCollector.php b/src/di/src/Aop/AspectCollector.php
index 84b27d060..c417dec7c 100644
--- a/src/di/src/Aop/AspectCollector.php
+++ b/src/di/src/Aop/AspectCollector.php
@@ -9,7 +9,7 @@
*
* Tracks which aspect classes target which classes/methods,
* and their execution priority. Used by ProxyManager to determine
- * which classes need proxy generation and by ProxyTrait at runtime
+ * which classes need proxy generation and by ProxyDispatcher at runtime
* to resolve the aspect pipeline for each method call.
*/
class AspectCollector
diff --git a/src/di/src/Aop/AspectManager.php b/src/di/src/Aop/AspectManager.php
index cb510a03e..75b17b379 100644
--- a/src/di/src/Aop/AspectManager.php
+++ b/src/di/src/Aop/AspectManager.php
@@ -41,14 +41,6 @@ public static function set(string $class, string $method, array $value): void
static::$container[$class][$method] = $value;
}
- /**
- * Append an aspect to the resolved list for a class method.
- */
- public static function insert(string $class, string $method, string $value): void
- {
- static::$container[$class][$method][] = $value;
- }
-
/**
* Flush all static state.
*/
diff --git a/src/di/src/Aop/Ast.php b/src/di/src/Aop/Ast.php
index 46755d2fe..1dccbeb6d 100644
--- a/src/di/src/Aop/Ast.php
+++ b/src/di/src/Aop/Ast.php
@@ -4,9 +4,6 @@
namespace Hypervel\Di\Aop;
-use Hypervel\Support\Composer;
-use PhpParser\Node\Stmt\ClassLike;
-use PhpParser\Node\Stmt\Namespace_;
use PhpParser\NodeTraverser;
use PhpParser\Parser;
use PhpParser\ParserFactory;
@@ -36,54 +33,25 @@ public function parse(string $code): ?array
/**
* Generate proxy code for the given class.
*
- * Reads the class source file, applies all registered AST visitors
- * (via AstVisitorRegistry), and returns the modified PHP code.
+ * Parse the supplied class source, apply all registered AST visitors,
+ * and return the modified PHP code.
*/
- public function proxy(string $className, ?string $sourceFilePath = null): string
+ public function proxy(string $className, string $sourceFilePath, string $sourceCode): string
{
- $code = $this->getCodeByClassName($className, $sourceFilePath);
- $stmts = $this->astParser->parse($code);
+ $stmts = $this->astParser->parse($sourceCode) ?? [];
$traverser = new NodeTraverser;
- $visitorMetadata = new VisitorMetadata($className);
+ $visitorMetadata = new VisitorMetadata($className, $sourceFilePath);
+
// Users can modify or replace node visitors via AstVisitorRegistry.
$queue = clone AstVisitorRegistry::getQueue();
+
foreach ($queue as $string) {
$visitor = new $string($visitorMetadata);
$traverser->addVisitor($visitor);
}
- $modifiedStmts = $traverser->traverse($stmts);
- return $this->printer->prettyPrintFile($modifiedStmts);
- }
- /**
- * Extract the fully qualified class name from parsed statements.
- */
- public function parseClassByStmts(array $stmts): string
- {
- $namespace = $className = '';
- foreach ($stmts as $stmt) {
- if ($stmt instanceof Namespace_ && $stmt->name) {
- $namespace = $stmt->name->toString();
- foreach ($stmt->stmts as $node) {
- if (($node instanceof ClassLike) && $node->name) {
- $className = $node->name->toString();
- break;
- }
- }
- }
- }
- return ($namespace && $className) ? $namespace . '\\' . $className : '';
- }
+ $modifiedStmts = $traverser->traverse($stmts);
- /**
- * Read the source code for a class from its file.
- */
- private function getCodeByClassName(string $className, ?string $sourceFilePath = null): string
- {
- $file = $sourceFilePath ?? Composer::getLoader()->findFile($className);
- if (! $file) {
- return '';
- }
- return file_get_contents($file);
+ return $this->printer->prettyPrintFile($modifiedStmts);
}
}
diff --git a/src/di/src/Aop/ProceedingJoinPoint.php b/src/di/src/Aop/ProceedingJoinPoint.php
index 8077f480f..a1c91fa17 100644
--- a/src/di/src/Aop/ProceedingJoinPoint.php
+++ b/src/di/src/Aop/ProceedingJoinPoint.php
@@ -54,15 +54,25 @@ public function processOriginalMethod(): mixed
public function getArguments(): array
{
$result = [];
+
foreach ($this->arguments['order'] ?? [] as $order) {
- $result[] = $this->arguments['keys'][$order];
- }
+ if (($this->arguments['variadic'] ?? '') !== $order) {
+ $result[] = &$this->arguments['keys'][$order];
+
+ continue;
+ }
- // Variable arguments are always placed at the end.
- if (isset($this->arguments['variadic'], $order) && $order === $this->arguments['variadic']) {
- $variadic = array_pop($result);
- $result = array_merge($result, $variadic);
+ foreach ($this->arguments['keys'][$order] as $key => &$value) {
+ if (is_string($key)) {
+ $result[$key] = &$value;
+ } else {
+ $result[] = &$value;
+ }
+ }
+
+ unset($value);
}
+
return $result;
}
diff --git a/src/di/src/Aop/ProxyCallVisitor.php b/src/di/src/Aop/ProxyCallVisitor.php
index 6e2d61b0f..0756eae62 100644
--- a/src/di/src/Aop/ProxyCallVisitor.php
+++ b/src/di/src/Aop/ProxyCallVisitor.php
@@ -4,45 +4,100 @@
namespace Hypervel\Di\Aop;
-use Hypervel\Support\Composer;
+use Hypervel\Di\Exceptions\InvalidDefinitionException;
use PhpParser\Node;
use PhpParser\Node\Arg;
-use PhpParser\Node\ClosureUse;
+use PhpParser\Node\ArrayItem;
use PhpParser\Node\Expr\Array_;
-use PhpParser\Node\Expr\ArrayItem;
+use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\Assign;
+use PhpParser\Node\Expr\BinaryOp\Coalesce;
use PhpParser\Node\Expr\Closure;
+use PhpParser\Node\Expr\ClosureUse;
+use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\StaticCall;
+use PhpParser\Node\Expr\Ternary;
use PhpParser\Node\Expr\Variable;
+use PhpParser\Node\FunctionLike;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
-use PhpParser\Node\Scalar\MagicConst\Class_ as MagicConstClass;
+use PhpParser\Node\Name\FullyQualified;
+use PhpParser\Node\Name\Relative;
+use PhpParser\Node\Param;
+use PhpParser\Node\Scalar\Int_;
use PhpParser\Node\Scalar\MagicConst\Dir as MagicConstDir;
use PhpParser\Node\Scalar\MagicConst\File as MagicConstFile;
use PhpParser\Node\Scalar\MagicConst\Function_ as MagicConstFunction;
+use PhpParser\Node\Scalar\MagicConst\Line as MagicConstLine;
use PhpParser\Node\Scalar\MagicConst\Method as MagicConstMethod;
use PhpParser\Node\Scalar\MagicConst\Trait_ as MagicConstTrait;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Class_;
+use PhpParser\Node\Stmt\ClassLike;
use PhpParser\Node\Stmt\ClassMethod;
-use PhpParser\Node\Stmt\Enum_;
use PhpParser\Node\Stmt\Expression;
+use PhpParser\Node\Stmt\Function_;
+use PhpParser\Node\Stmt\GroupUse;
+use PhpParser\Node\Stmt\Interface_;
+use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\Node\Stmt\TraitUse;
+use PhpParser\Node\Stmt\Use_;
+use PhpParser\NodeFinder;
use PhpParser\NodeVisitorAbstract;
class ProxyCallVisitor extends NodeVisitorAbstract
{
+ private const ARGUMENT_FUNCTIONS = [
+ 'func_get_arg',
+ 'func_get_args',
+ 'func_num_args',
+ ];
+
+ private const INDIRECT_CALL_FUNCTIONS = [
+ 'call_user_func',
+ 'call_user_func_array',
+ ];
+
+ private ?ClassLike $targetClassLike = null;
+
/**
- * Define the proxy handler trait here.
+ * @var array
*/
- private array $proxyTraits = [
- ProxyTrait::class,
- ];
+ private array $classLikeStack = [];
+
+ private ?ClassMethod $activeMethod = null;
+
+ /**
+ * @var array
+ */
+ private array $nestedFunctionStack = [];
+
+ private string $targetClassName = '';
+
+ private string $targetNamespace = '';
+
+ /**
+ * @var array
+ */
+ private array $functionImports = [];
+
+ /**
+ * @var array
+ */
+ private array $reservedMethodNames = [];
+
+ private string $helperMethodName = '';
+
+ private string $argumentCountVariable = '';
+
+ private string $variadicArgumentsVariable = '';
- private bool $shouldRewrite = false;
+ private bool $usesArgumentCount = false;
+
+ private bool $usesArgumentValues = false;
public function __construct(protected VisitorMetadata $visitorMetadata)
{
@@ -50,20 +105,40 @@ public function __construct(protected VisitorMetadata $visitorMetadata)
public function beforeTraverse(array $nodes)
{
- foreach ($nodes as $namespace) {
- if ($namespace instanceof Node\Stmt\Declare_) {
- continue;
- }
+ $this->targetClassName = ltrim($this->visitorMetadata->className, '\\');
+ $candidates = $this->findNamedClassLikes($nodes);
- if (! $namespace instanceof Node\Stmt\Namespace_) {
- break;
- }
+ if (count($candidates) > 1) {
+ throw new InvalidDefinitionException(
+ "Unable to generate an AOP proxy for [{$this->targetClassName}]: its source file "
+ . 'contains multiple named classes, interfaces, traits, or enums. Split each named class-like '
+ . 'into its own file before targeting it with an aspect.'
+ );
+ }
- foreach ($namespace->stmts as $class) {
- if ($class instanceof Node\Stmt\ClassLike) {
- $this->visitorMetadata->classLike = get_class($class);
- }
- }
+ if ($candidates === []) {
+ throw new InvalidDefinitionException(
+ "Unable to generate an AOP proxy for [{$this->targetClassName}]: "
+ . 'the source file does not declare that class-like.'
+ );
+ }
+
+ $candidate = $candidates[0];
+
+ if (strcasecmp($candidate['class'], $this->targetClassName) !== 0) {
+ throw new InvalidDefinitionException(
+ "Unable to generate an AOP proxy for [{$this->targetClassName}]: "
+ . "the source file declares [{$candidate['class']}] instead."
+ );
+ }
+
+ $this->targetClassLike = $candidate['node'];
+ $this->targetNamespace = $candidate['namespace'];
+ $this->functionImports = $this->collectFunctionImports($candidate['statements']);
+ $this->visitorMetadata->classLike = $candidate['node']::class;
+
+ foreach ($candidate['node']->getMethods() as $method) {
+ $this->reservedMethodNames[strtolower($method->name->toString())] = true;
}
return null;
@@ -71,10 +146,28 @@ public function beforeTraverse(array $nodes)
public function enterNode(Node $node)
{
- switch ($node) {
- case $node instanceof ClassMethod:
- $this->shouldRewrite = $this->shouldRewrite($node);
- break;
+ if ($node instanceof ClassLike) {
+ $this->classLikeStack[] = $node === $this->targetClassLike;
+ }
+
+ if ($this->activeMethod instanceof ClassMethod && $node instanceof FunctionLike && $node !== $this->activeMethod) {
+ $this->nestedFunctionStack[] = $node;
+ }
+
+ if (
+ $node instanceof ClassMethod
+ && $this->activeMethod === null
+ && end($this->classLikeStack) === true
+ && $this->shouldRewrite($node)
+ ) {
+ if ($node->byRef) {
+ throw new InvalidDefinitionException(
+ "Unable to apply an aspect to [{$this->targetClassName}::{$node->name->toString()}]: "
+ . 'methods that return by reference cannot be intercepted safely.'
+ );
+ }
+
+ $this->beginMethodRewrite($node);
}
return null;
@@ -82,204 +175,727 @@ public function enterNode(Node $node)
public function leaveNode(Node $node)
{
- switch ($node) {
- case $node instanceof ClassMethod:
- if (! $this->shouldRewrite($node)) {
- return $node;
- }
- // Rewrite the method to proxy call method.
- return $this->rewriteMethod($node);
- case $node instanceof Trait_:
- case $node instanceof Enum_:
- case $node instanceof Class_ && ! $node->isAnonymous():
- // Add use proxy traits.
- $stmts = $node->stmts;
- if ($stmt = $this->buildProxyCallTraitUseStatement()) {
- array_unshift($stmts, $stmt);
- }
- $node->stmts = $stmts;
- unset($stmts);
- return $node;
- case $node instanceof MagicConstFunction:
- // Rewrite __FUNCTION__ to $__function__ variable.
- if ($this->shouldRewrite) {
- return new Variable('__function__');
- }
- break;
- case $node instanceof MagicConstMethod:
- // Rewrite __METHOD__ to $__method__ variable.
- if ($this->shouldRewrite) {
- return new Variable('__method__');
- }
- break;
- case $node instanceof MagicConstDir:
- // Rewrite __DIR__ as the real directory path
- if ($file = Composer::getLoader()->findFile($this->visitorMetadata->className)) {
- return new String_(dirname(realpath($file)));
+ if ($this->activeMethod instanceof ClassMethod) {
+ if ($node instanceof MagicConstFile) {
+ return new String_($this->visitorMetadata->sourceFilePath, $node->getAttributes());
+ }
+
+ if ($node instanceof MagicConstDir) {
+ return new String_(dirname($this->visitorMetadata->sourceFilePath), $node->getAttributes());
+ }
+
+ if ($node instanceof MagicConstLine) {
+ return new Int_($node->getStartLine(), $node->getAttributes());
+ }
+
+ if ($node instanceof MagicConstFunction || $node instanceof MagicConstMethod) {
+ if ($replacement = $this->rewriteFunctionMagicConstant($node)) {
+ return $replacement;
}
- break;
- case $node instanceof MagicConstFile:
- // Rewrite __FILE__ to the real file path
- if ($file = Composer::getLoader()->findFile($this->visitorMetadata->className)) {
- return new String_(realpath($file));
+ }
+
+ if ($this->nestedFunctionStack === [] && $node instanceof FuncCall && $node->name instanceof Name) {
+ $this->rejectIndirectArgumentCall($node);
+
+ if ($replacement = $this->rewriteArgumentFunction($node)) {
+ return $replacement;
}
- break;
+ }
+ }
+
+ if ($node === $this->activeMethod) {
+ $rewritten = $this->rewriteMethod($node);
+ $this->finishMethodRewrite();
+
+ return $rewritten;
+ }
+
+ if ($this->activeMethod instanceof ClassMethod && $node instanceof FunctionLike) {
+ array_pop($this->nestedFunctionStack);
+ }
+
+ if ($node instanceof ClassLike) {
+ $isTarget = array_pop($this->classLikeStack);
+
+ if ($isTarget && ! $node instanceof Interface_) {
+ array_unshift($node->stmts, new TraitUse([
+ new FullyQualified(ProxyMarker::class),
+ ]));
+ }
+
+ return $node;
}
+
return null;
}
/**
- * Build `use ProxyTrait;`.
+ * Rewrite an intercepted method into its wrapper and private original-body helper.
+ *
+ * @return array{ClassMethod, ClassMethod}
*/
- private function buildProxyCallTraitUseStatement(): ?TraitUse
+ private function rewriteMethod(ClassMethod $node): array
{
- $traits = [];
- foreach ($this->proxyTraits as $proxyTrait) {
- if (! is_string($proxyTrait) || ! trait_exists($proxyTrait)) {
+ $helper = clone $node;
+ $helper->name = new Identifier($this->helperMethodName);
+ $helper->flags = ($node->flags & Class_::MODIFIER_STATIC) | Class_::MODIFIER_PRIVATE;
+ $helper->attrGroups = [];
+ $helper->setAttribute('comments', []);
+ $helper->params = $this->buildHelperParameters($node->params);
+
+ $statements = [];
+
+ if ($this->usesArgumentCount) {
+ $statements[] = new Expression(new Assign(
+ new Variable($this->argumentCountVariable),
+ new FuncCall(new FullyQualified('func_num_args'))
+ ));
+ }
+
+ $dispatch = new StaticCall(new FullyQualified(ProxyDispatcher::class), 'dispatch', [
+ new Arg($this->getTargetMagicConstant()),
+ new Arg(new MagicConstFunction),
+ new Arg($this->buildArgumentMap($node->params)),
+ new Arg($this->buildForwardingClosure($node)),
+ ]);
+
+ if ($this->returnsByExpression($node)) {
+ $statements[] = new Expression($dispatch);
+ } else {
+ $statements[] = new Return_($dispatch);
+ }
+
+ $node->stmts = $statements;
+
+ return [$node, $helper];
+ }
+
+ /**
+ * Build the private helper's parameters.
+ *
+ * @param array $parameters
+ * @return array
+ */
+ private function buildHelperParameters(array $parameters): array
+ {
+ $helperParameters = [];
+
+ if ($this->usesArgumentCount) {
+ $helperParameters[] = new Param(
+ new Variable($this->argumentCountVariable),
+ type: new Identifier('int')
+ );
+ }
+
+ if ($this->usesArgumentValues) {
+ $helperParameters[] = new Param(
+ new Variable($this->variadicArgumentsVariable),
+ type: new Identifier('array')
+ );
+ }
+
+ foreach ($parameters as $parameter) {
+ $parameter = clone $parameter;
+ $parameter->attrGroups = [];
+ $parameter->flags = 0;
+ $helperParameters[] = $parameter;
+ }
+
+ return $helperParameters;
+ }
+
+ /**
+ * Build the closure that forwards aspect-adjusted arguments to the helper.
+ */
+ private function buildForwardingClosure(ClassMethod $method): Closure
+ {
+ $parameters = [];
+
+ foreach ($method->params as $parameter) {
+ $parameter = clone $parameter;
+ $parameter->attrGroups = [];
+ $parameter->default = null;
+ $parameter->flags = 0;
+ $parameters[] = $parameter;
+ }
+
+ $uses = $this->usesArgumentCount
+ ? [new ClosureUse(new Variable($this->argumentCountVariable))]
+ : [];
+
+ $helperCall = new StaticCall(new Name('self'), $this->helperMethodName, $this->buildHelperArguments($method));
+ $statements = $this->returnsByExpression($method)
+ ? [new Expression($helperCall)]
+ : [new Return_($helperCall)];
+
+ return new Closure([
+ 'static' => $method->isStatic(),
+ 'params' => $parameters,
+ 'uses' => $uses,
+ 'stmts' => $statements,
+ ]);
+ }
+
+ /**
+ * Build the arguments passed from the forwarding closure to the helper.
+ *
+ * @return array
+ */
+ private function buildHelperArguments(ClassMethod $method): array
+ {
+ $arguments = [];
+
+ if ($this->usesArgumentCount) {
+ $arguments[] = new Arg(new Variable($this->argumentCountVariable));
+ }
+
+ if ($this->usesArgumentValues) {
+ $variadic = $this->getVariadicParameter($method->params);
+
+ if ($variadic instanceof Param) {
+ $arguments[] = new Arg(new StaticCall(
+ new FullyQualified(ProxyDispatcher::class),
+ 'captureVariadicArguments',
+ [
+ new Arg(new Variable($variadic->var->name)),
+ new Arg(new FuncCall(new FullyQualified('max'), [
+ new Arg(new Int_(0)),
+ new Arg(new Node\Expr\BinaryOp\Minus(
+ new Variable($this->argumentCountVariable),
+ new Int_($this->getFixedParameterCount($method->params))
+ )),
+ ])),
+ new Arg(new ConstFetch(new Name($variadic->byRef ? 'true' : 'false'))),
+ ]
+ ));
+ } else {
+ $arguments[] = new Arg(new Array_([], ['kind' => Array_::KIND_SHORT]));
+ }
+ }
+
+ foreach ($method->params as $parameter) {
+ $arguments[] = new Arg(
+ new Variable($parameter->var->name),
+ unpack: $parameter->variadic
+ );
+ }
+
+ return $arguments;
+ }
+
+ /**
+ * Build the join-point argument map.
+ *
+ * @param array $parameters
+ */
+ private function buildArgumentMap(array $parameters): Array_
+ {
+ $order = [];
+ $keys = [];
+ $variadic = '';
+
+ foreach ($parameters as $parameter) {
+ $name = $parameter->var->name;
+ $order[] = new ArrayItem(new String_($name));
+ $keys[] = new ArrayItem(
+ new Variable($name),
+ new String_($name),
+ $parameter->byRef
+ );
+
+ if ($parameter->variadic) {
+ $variadic = $name;
+ }
+ }
+
+ return new Array_([
+ new ArrayItem(new Array_($order, ['kind' => Array_::KIND_SHORT]), new String_('order')),
+ new ArrayItem(new Array_($keys, ['kind' => Array_::KIND_SHORT]), new String_('keys')),
+ new ArrayItem(new String_($variadic), new String_('variadic')),
+ ], ['kind' => Array_::KIND_SHORT]);
+ }
+
+ /**
+ * Rewrite one direct argument-introspection call.
+ */
+ private function rewriteArgumentFunction(FuncCall $call): ?Node\Expr
+ {
+ $resolution = $this->resolveArgumentFunction($call->name);
+
+ if ($resolution === null || $call->isFirstClassCallable()) {
+ return null;
+ }
+
+ $arguments = $call->getArgs();
+
+ if (array_any($arguments, static fn (Arg $argument): bool => $argument->unpack)) {
+ throw new InvalidDefinitionException(
+ "Unable to apply an aspect to [{$this->targetClassName}::{$this->activeMethod?->name->toString()}]: "
+ . "calls to {$resolution['function']}() cannot use argument unpacking. "
+ . "Call {$resolution['function']}() without argument unpacking."
+ );
+ }
+
+ if (! $this->hasValidArgumentFunctionArity($resolution['function'], $arguments)) {
+ return null;
+ }
+
+ $replacement = $this->buildArgumentFunctionReplacement($resolution['function'], $call);
+
+ if ($resolution['fallback'] === null) {
+ return $replacement;
+ }
+
+ return new Ternary(
+ new FuncCall(new FullyQualified('function_exists'), [
+ new Arg(new String_($resolution['fallback'])),
+ ]),
+ new FuncCall(
+ new FullyQualified($resolution['fallback']),
+ array_map(static fn (Arg $argument) => clone $argument, $call->args)
+ ),
+ $replacement,
+ $call->getAttributes()
+ );
+ }
+
+ /**
+ * Preserve method and closure descriptors from the original source scope.
+ */
+ private function rewriteFunctionMagicConstant(MagicConstFunction|MagicConstMethod $constant): ?String_
+ {
+ if (end($this->classLikeStack) !== true) {
+ return null;
+ }
+
+ if ($this->nestedFunctionStack === []) {
+ $value = $constant instanceof MagicConstFunction
+ ? $this->activeMethod?->name->toString()
+ : $this->targetClassName . '::' . $this->activeMethod?->name->toString();
+
+ return new String_($value, $constant->getAttributes());
+ }
+
+ $descriptor = $this->buildClosureDescriptor();
+
+ return $descriptor === null
+ ? null
+ : new String_($descriptor, $constant->getAttributes());
+ }
+
+ /**
+ * Build PHP's closure descriptor using original lexical names and lines.
+ */
+ private function buildClosureDescriptor(): ?string
+ {
+ $base = $this->targetClassName . '::' . $this->activeMethod?->name->toString() . '()';
+ $closures = [];
+
+ foreach ($this->nestedFunctionStack as $functionLike) {
+ if ($functionLike instanceof ClassMethod) {
+ return null;
+ }
+
+ if ($functionLike instanceof Function_) {
+ $base = ($this->targetNamespace === '' ? '' : $this->targetNamespace . '\\')
+ . $functionLike->name->toString()
+ . '()';
+ $closures = [];
+
continue;
}
- // Add backslash prefix if the proxy trait does not start with backslash.
- $proxyTrait[0] !== '\\' && $proxyTrait = '\\' . $proxyTrait;
- $traits[] = new Name($proxyTrait);
+
+ if ($functionLike instanceof Closure || $functionLike instanceof ArrowFunction) {
+ $closures[] = $functionLike;
+
+ continue;
+ }
+
+ return null;
}
- if (empty($traits)) {
+ if ($closures === []) {
return null;
}
- return new TraitUse($traits);
+
+ foreach ($closures as $closure) {
+ $base = $this->formatClosureDescriptor($base, $closure->getStartLine());
+ }
+
+ return $base;
}
/**
- * Rewrite a normal class method to a proxy call method,
- * include normal class method and static method.
+ * Format the PHP 8.4 closure descriptor shape.
*/
- private function rewriteMethod(ClassMethod $node): ClassMethod
+ private function formatClosureDescriptor(string $parent, int $line): string
{
- // Build the static proxy call method base on the original method.
- $shouldReturn = true;
- $returnType = $node->getReturnType();
- if ($returnType instanceof Identifier && $returnType->name === 'void') {
- $shouldReturn = false;
- }
- $staticCall = new StaticCall(new Name('self'), '__proxyCall', [
- // __CLASS__
- new Arg($this->getMagicConst()),
- // __FUNCTION__
- new Arg(new MagicConstFunction),
- // ['order' => ['param1', 'param2'], 'keys' => compact('param1', 'param2'), 'variadic' => 'param2']
- new Arg($this->getArguments($node->getParams())),
- // A closure that wrapped original method code.
- new Arg(new Closure([
- 'params' => $this->filterModifier($node->getParams()),
- 'uses' => [
- new ClosureUse(new Variable('__function__')),
- new ClosureUse(new Variable('__method__')),
- ],
- 'stmts' => $node->stmts,
- ])),
- ]);
- $stmts = $this->unshiftMagicMethods([]);
- if ($shouldReturn) {
- $stmts[] = new Return_($staticCall);
- } else {
- $stmts[] = new Expression($staticCall);
+ return "{closure:{$parent}:{$line}}";
+ }
+
+ /**
+ * Resolve whether a call can invoke a built-in argument-introspection function.
+ *
+ * @return null|array{function: string, fallback: ?string}
+ */
+ private function resolveArgumentFunction(Name $name): ?array
+ {
+ if ($name instanceof Relative) {
+ return null;
+ }
+
+ if ($name instanceof FullyQualified) {
+ $function = strtolower($name->toString());
+
+ return in_array($function, self::ARGUMENT_FUNCTIONS, true)
+ ? ['function' => $function, 'fallback' => null]
+ : null;
+ }
+
+ if (! $name->isUnqualified()) {
+ return null;
+ }
+
+ $localName = strtolower($name->toString());
+
+ if (isset($this->functionImports[$localName])) {
+ $function = strtolower($this->functionImports[$localName]);
+
+ return in_array($function, self::ARGUMENT_FUNCTIONS, true)
+ ? ['function' => $function, 'fallback' => null]
+ : null;
+ }
+
+ if (! in_array($localName, self::ARGUMENT_FUNCTIONS, true)) {
+ return null;
+ }
+
+ return [
+ 'function' => $localName,
+ 'fallback' => $this->targetNamespace === ''
+ ? null
+ : $this->targetNamespace . '\\' . $localName,
+ ];
+ }
+
+ /**
+ * Build the preserved implementation of one argument-introspection function.
+ */
+ private function buildArgumentFunctionReplacement(string $function, FuncCall $call): Node\Expr
+ {
+ $this->usesArgumentCount = true;
+
+ if ($function === 'func_num_args') {
+ return new Variable($this->argumentCountVariable, $call->getAttributes());
+ }
+
+ $this->usesArgumentValues = true;
+ $arguments = [
+ new Arg(new Variable($this->argumentCountVariable)),
+ new Arg($this->buildCurrentFixedArguments()),
+ new Arg(new Variable($this->variadicArgumentsVariable)),
+ ];
+
+ if ($function === 'func_get_arg') {
+ $arguments[] = clone $call->args[0];
}
- $node->stmts = $stmts;
- return $node;
+
+ return new StaticCall(
+ new FullyQualified(ProxyDispatcher::class),
+ $function === 'func_get_arg' ? 'resolveArgument' : 'resolveArguments',
+ $arguments,
+ $call->getAttributes()
+ );
}
/**
- * @param Node\Param[] $params
- * @return Node\Param[]
+ * Build the helper's current fixed-parameter values.
*/
- private function filterModifier(array $params): array
+ private function buildCurrentFixedArguments(): Array_
{
- return array_map(function (Node\Param $param) {
- $tempParam = clone $param;
- $tempParam->flags &= ~Class_::VISIBILITY_MODIFIER_MASK & ~Class_::MODIFIER_READONLY;
- return $tempParam;
- }, $params);
+ $arguments = [];
+
+ foreach ($this->activeMethod->params as $parameter) {
+ if ($parameter->variadic) {
+ break;
+ }
+
+ $arguments[] = new ArrayItem(new Coalesce(
+ new Variable($parameter->var->name),
+ new ConstFetch(new Name('null'))
+ ));
+ }
+
+ return new Array_($arguments, ['kind' => Array_::KIND_SHORT]);
}
/**
- * @param Node\Param[] $params
+ * Reject indirect argument-introspection calls whose caller-frame behavior cannot be preserved.
*/
- private function getArguments(array $params): Array_
+ private function rejectIndirectArgumentCall(FuncCall $call): void
{
- if (empty($params)) {
- return new Array_([
- new ArrayItem(
- key: new String_('keys'),
- value: new Array_([], ['kind' => Array_::KIND_SHORT]),
- ),
- ], ['kind' => Array_::KIND_SHORT]);
- }
- // ['param1', 'param2', ...]
- $methodParamsList = new Array_(
- array_map(fn (Node\Param $param) => new ArrayItem(new String_($param->var->name)), $params),
- ['kind' => Array_::KIND_SHORT]
+ if ($call->isFirstClassCallable() || ! $this->resolvesToGlobalFunction($call->name, self::INDIRECT_CALL_FUNCTIONS)) {
+ return;
+ }
+
+ $callback = $call->args[0]->value ?? null;
+
+ if (! $callback instanceof String_) {
+ return;
+ }
+
+ $function = strtolower($callback->value);
+
+ if (! in_array($function, self::ARGUMENT_FUNCTIONS, true)) {
+ return;
+ }
+
+ throw new InvalidDefinitionException(
+ "Unable to apply an aspect to [{$this->targetClassName}::{$this->activeMethod?->name->toString()}]: "
+ . "indirect calls to {$function}() cannot preserve the original method frame. Call {$function}() directly."
);
- return new Array_([
- new ArrayItem(
- key: new String_('order'),
- value: $methodParamsList,
- ),
- new ArrayItem(
- key: new String_('keys'),
- value: new FuncCall(new Name('compact'), [new Arg($methodParamsList)])
- ),
- new ArrayItem(
- key: new String_('variadic'),
- value: $this->getVariadicParamName($params),
- )], ['kind' => Array_::KIND_SHORT]);
}
/**
- * @param Node\Param[] $params
+ * Determine whether a function name can resolve to one of the given global functions.
+ *
+ * Bare calls in a namespace may fall back to a global function at runtime. For
+ * indirect argument-introspection calls, rejecting that ambiguous source shape
+ * is preferable to silently exposing the generated helper's call frame.
+ *
+ * @param array $functions
+ */
+ private function resolvesToGlobalFunction(Name $name, array $functions): bool
+ {
+ if ($name instanceof Relative) {
+ return false;
+ }
+
+ if ($name instanceof FullyQualified) {
+ return in_array(strtolower($name->toString()), $functions, true);
+ }
+
+ if (! $name->isUnqualified()) {
+ return false;
+ }
+
+ $localName = strtolower($name->toString());
+
+ if (isset($this->functionImports[$localName])) {
+ return in_array(strtolower($this->functionImports[$localName]), $functions, true);
+ }
+
+ return in_array($localName, $functions, true);
+ }
+
+ /**
+ * Determine whether a direct argument-introspection call has its native arity.
+ *
+ * @param array $arguments
+ */
+ private function hasValidArgumentFunctionArity(string $function, array $arguments): bool
+ {
+ return count($arguments) === ($function === 'func_get_arg' ? 1 : 0);
+ }
+
+ /**
+ * Begin tracking one target method rewrite.
+ */
+ private function beginMethodRewrite(ClassMethod $method): void
+ {
+ $this->activeMethod = $method;
+ $this->nestedFunctionStack = [];
+ $hash = substr(hash('sha256', $this->targetClassName . '::' . $method->name->toString()), 0, 12);
+ $this->helperMethodName = $this->reserveMethodName("__hypervelAopOriginal_{$hash}");
+
+ $usedVariables = [];
+ $finder = new NodeFinder;
+
+ foreach ($finder->findInstanceOf($method, Variable::class) as $variable) {
+ if (is_string($variable->name)) {
+ $usedVariables[$variable->name] = true;
+ }
+ }
+
+ $this->argumentCountVariable = $this->uniqueVariableName("__hypervelAopCount_{$hash}", $usedVariables);
+ $usedVariables[$this->argumentCountVariable] = true;
+ $this->variadicArgumentsVariable = $this->uniqueVariableName("__hypervelAopVariadic_{$hash}", $usedVariables);
+ $this->usesArgumentCount = false;
+ $this->usesArgumentValues = false;
+ }
+
+ /**
+ * Finish tracking the current method rewrite.
+ */
+ private function finishMethodRewrite(): void
+ {
+ $this->activeMethod = null;
+ $this->nestedFunctionStack = [];
+ $this->helperMethodName = '';
+ $this->argumentCountVariable = '';
+ $this->variadicArgumentsVariable = '';
+ $this->usesArgumentCount = false;
+ $this->usesArgumentValues = false;
+ }
+
+ /**
+ * Reserve a deterministic generated method name without colliding with source methods.
+ */
+ private function reserveMethodName(string $base): string
+ {
+ $name = $base;
+ $suffix = 0;
+
+ while (isset($this->reservedMethodNames[strtolower($name)])) {
+ $name = $base . '_' . ++$suffix;
+ }
+
+ $this->reservedMethodNames[strtolower($name)] = true;
+
+ return $name;
+ }
+
+ /**
+ * Build a generated variable name that does not change source-local behavior.
+ *
+ * @param array $usedVariables
+ */
+ private function uniqueVariableName(string $base, array $usedVariables): string
+ {
+ $name = $base;
+ $suffix = 0;
+
+ while (isset($usedVariables[$name])) {
+ $name = $base . '_' . ++$suffix;
+ }
+
+ return $name;
+ }
+
+ /**
+ * Find every named class-like and its namespace.
+ *
+ * @return array}>
+ */
+ private function findNamedClassLikes(array $nodes): array
+ {
+ $finder = new NodeFinder;
+ $candidates = [];
+
+ foreach ($nodes as $node) {
+ $namespace = $node instanceof Namespace_ && $node->name !== null
+ ? $node->name->toString()
+ : '';
+ $statements = $node instanceof Namespace_ ? $node->stmts : [$node];
+
+ foreach ($finder->findInstanceOf($statements, ClassLike::class) as $classLike) {
+ if ($classLike->name === null) {
+ continue;
+ }
+
+ $class = $classLike->name->toString();
+ $candidates[] = [
+ 'class' => $namespace === '' ? $class : $namespace . '\\' . $class,
+ 'namespace' => $namespace,
+ 'node' => $classLike,
+ 'statements' => $node instanceof Namespace_ ? $node->stmts : $nodes,
+ ];
+ }
+ }
+
+ return $candidates;
+ }
+
+ /**
+ * Collect function import aliases for the target namespace.
+ *
+ * @param array $statements
+ * @return array
*/
- private function getVariadicParamName(array $params): String_
+ private function collectFunctionImports(array $statements): array
{
- foreach ($params as $param) {
- if ($param->variadic) {
- return new String_($param->var->name);
+ $imports = [];
+
+ foreach ($statements as $statement) {
+ if (! $statement instanceof Use_ && ! $statement instanceof GroupUse) {
+ continue;
+ }
+
+ foreach ($statement->uses as $use) {
+ $type = $use->type !== Use_::TYPE_UNKNOWN ? $use->type : $statement->type;
+
+ if ($type !== Use_::TYPE_FUNCTION) {
+ continue;
+ }
+
+ $name = $statement instanceof GroupUse
+ ? $statement->prefix->toString() . '\\' . $use->name->toString()
+ : $use->name->toString();
+ $alias = $use->alias?->toString() ?? $use->name->getLast();
+ $imports[strtolower($alias)] = ltrim($name, '\\');
}
}
- return new String_('');
+
+ return $imports;
+ }
+
+ /**
+ * Get the class or trait identity used by the runtime aspect registry.
+ */
+ private function getTargetMagicConstant(): Node\Scalar\MagicConst
+ {
+ return $this->targetClassLike instanceof Trait_
+ ? new MagicConstTrait
+ : new Node\Scalar\MagicConst\Class_;
+ }
+
+ /**
+ * Determine whether the method's return contract forbids a return statement.
+ */
+ private function returnsByExpression(ClassMethod $method): bool
+ {
+ $returnType = $method->getReturnType();
+
+ return $returnType instanceof Identifier
+ && in_array(strtolower($returnType->name), ['never', 'void'], true);
}
/**
- * Prepend __function__ and __method__ variable assignments.
+ * Get the variadic parameter, if present.
+ *
+ * @param array $parameters
*/
- private function unshiftMagicMethods(array $stmts = []): array
+ private function getVariadicParameter(array $parameters): ?Param
{
- $magicConstFunction = new Expression(new Assign(new Variable('__function__'), new MagicConstFunction));
- $magicConstMethod = new Expression(new Assign(new Variable('__method__'), new MagicConstMethod));
- array_unshift($stmts, $magicConstFunction, $magicConstMethod);
- return $stmts;
+ foreach ($parameters as $parameter) {
+ if ($parameter->variadic) {
+ return $parameter;
+ }
+ }
+
+ return null;
}
/**
- * Get the appropriate magic constant node for the class-like type.
+ * Count the fixed parameters before a possible variadic parameter.
+ *
+ * @param array $parameters
*/
- private function getMagicConst(): Node\Scalar\MagicConst
+ private function getFixedParameterCount(array $parameters): int
{
- return match ($this->visitorMetadata->classLike) {
- Trait_::class => new MagicConstTrait,
- default => new MagicConstClass,
- };
+ return count($parameters) - ($this->getVariadicParameter($parameters) instanceof Param ? 1 : 0);
}
/**
- * Determine if the method should be rewritten to a proxy call.
+ * Determine if the method should be rewritten.
*/
private function shouldRewrite(ClassMethod $node): bool
{
- if ($this->visitorMetadata->classLike === Node\Stmt\Interface_::class || $node->isAbstract()) {
+ if ($this->visitorMetadata->classLike === Interface_::class || $node->isAbstract()) {
return false;
}
- $rewriteCollection = Aspect::parse($this->visitorMetadata->className);
-
- return $rewriteCollection->shouldRewrite($node->name->toString());
+ return Aspect::parse($this->visitorMetadata->className)
+ ->shouldRewrite($node->name->toString());
}
}
diff --git a/src/di/src/Aop/ProxyDispatcher.php b/src/di/src/Aop/ProxyDispatcher.php
new file mode 100644
index 000000000..6a4dbb584
--- /dev/null
+++ b/src/di/src/Aop/ProxyDispatcher.php
@@ -0,0 +1,144 @@
+processOriginalMethod();
+ }
+
+ return (new Pipeline(Container::getInstance()))
+ ->via('process')
+ ->through($aspects)
+ ->send($proceedingJoinPoint)
+ ->then(static fn (ProceedingJoinPoint $point) => $point->processOriginalMethod());
+ }
+
+ /**
+ * Reconstruct the arguments visible to the intercepted method.
+ */
+ public static function resolveArguments(int $count, array $fixed, array $variadic = []): array
+ {
+ $fixedCount = min($count, count($fixed));
+
+ return array_merge(
+ array_slice($fixed, 0, $fixedCount),
+ array_slice($variadic, 0, max(0, $count - $fixedCount))
+ );
+ }
+
+ /**
+ * Resolve one argument visible to the intercepted method.
+ */
+ public static function resolveArgument(int $count, array $fixed, array $variadic, int $position): mixed
+ {
+ if ($position < 0) {
+ throw new ValueError('func_get_arg(): Argument #1 ($position) must be greater than or equal to 0');
+ }
+
+ if ($position >= $count) {
+ throw new ValueError(
+ 'func_get_arg(): Argument #1 ($position) must be less than the number of the arguments passed '
+ . 'to the currently executed function'
+ );
+ }
+
+ return static::resolveArguments($count, $fixed, $variadic)[$position];
+ }
+
+ /**
+ * Capture the positional variadic arguments visible to the original call.
+ */
+ public static function captureVariadicArguments(array &$arguments, int $limit, bool $byReference): array
+ {
+ $captured = [];
+
+ foreach ($arguments as $key => &$argument) {
+ if (! is_int($key)) {
+ continue;
+ }
+
+ if (count($captured) >= $limit) {
+ break;
+ }
+
+ if ($byReference) {
+ $captured[] = &$argument;
+ } else {
+ $captured[] = $argument;
+ }
+ }
+
+ unset($argument);
+
+ return $captured;
+ }
+
+ /**
+ * Resolve and cache the aspects for a class method.
+ *
+ * @return array
+ */
+ private static function resolveAspects(string $className, string $methodName): array
+ {
+ if (AspectManager::has($className, $methodName)) {
+ return AspectManager::get($className, $methodName);
+ }
+
+ $matchedAspects = [];
+
+ foreach (AspectCollector::getClassRules() as $aspect => $rules) {
+ foreach ($rules as $rule) {
+ if (Aspect::isMatch($className, $methodName, $rule)) {
+ $matchedAspects[] = $aspect;
+ break;
+ }
+ }
+ }
+
+ $queue = new SplPriorityQueue;
+
+ foreach (array_unique($matchedAspects) as $aspect) {
+ $queue->insert($aspect, AspectCollector::getPriority($aspect));
+ }
+
+ $resolvedAspects = [];
+
+ while ($queue->valid()) {
+ $resolvedAspects[] = $queue->current();
+ $queue->next();
+ }
+
+ // Publish only the complete immutable list so another coroutine can never
+ // observe a cache entry while it is still being assembled.
+ AspectManager::set($className, $methodName, $resolvedAspects);
+
+ return $resolvedAspects;
+ }
+}
diff --git a/src/di/src/Aop/ProxyManager.php b/src/di/src/Aop/ProxyManager.php
index 88d0fee20..005900000 100644
--- a/src/di/src/Aop/ProxyManager.php
+++ b/src/di/src/Aop/ProxyManager.php
@@ -4,16 +4,16 @@
namespace Hypervel\Di\Aop;
+use Composer\InstalledVersions;
+use Hypervel\Di\Exceptions\InvalidDefinitionException;
use Hypervel\Filesystem\Filesystem;
+use Hypervel\Support\ClassMetadataCache;
+use InvalidArgumentException;
+use Throwable;
class ProxyManager
{
- /**
- * Source paths used to generate existing proxy files.
- *
- * @var array className => sourceFilePath
- */
- protected static array $generatedFrom = [];
+ private const FINGERPRINT_HEADER = '// Hypervel AOP fingerprint: ';
/**
* The classes that have been rewritten as proxies.
@@ -24,6 +24,8 @@ class ProxyManager
protected Filesystem $filesystem;
+ private ?string $commonFingerprint = null;
+
/**
* @param array $classMap Map of class names to their source file paths
* @param string $proxyDir Directory where proxy files are written
@@ -56,89 +58,67 @@ public function getProxyDir(): string
return $this->proxyDir;
}
- /**
- * Get the aspect classes grouped by their targeted proxy classes.
- */
- public function getAspectClasses(): array
- {
- $aspectClasses = [];
- $classesAspects = AspectCollector::getClassRules();
- foreach ($classesAspects as $aspect => $rules) {
- foreach ($rules as $rule) {
- if (isset($this->proxies[$rule])) {
- $aspectClasses[$aspect][$rule] = $this->proxies[$rule];
- }
- }
- }
- return $aspectClasses;
- }
-
/**
* Generate proxy files for the given classes.
+ *
+ * @param array $proxies
+ * @return array
*/
protected function generateProxyFiles(array $proxies = []): array
{
- $proxyFiles = [];
- if (! $proxies) {
- return $proxyFiles;
+ if ($proxies === []) {
+ return [];
}
- if (! file_exists($this->getProxyDir())) {
- mkdir($this->getProxyDir(), 0755, true);
+
+ if ($this->getProxyDir() === '') {
+ throw new InvalidArgumentException('The AOP proxy output directory must not be empty.');
}
- // Ast must not be a static instance — it reads source files which can trigger coroutine switches.
+
+ $this->filesystem->ensureDirectoryExists($this->getProxyDir());
+
+ // Ast must not be a static instance — source reads can trigger coroutine switches.
$ast = new Ast;
- foreach ($proxies as $className => $aspects) {
+ $proxyFiles = [];
+
+ foreach (array_keys($proxies) as $className) {
$proxyFiles[$className] = $this->putProxyFile($ast, $className);
}
+
return $proxyFiles;
}
/**
- * Write or skip a proxy file based on modification time.
+ * Generate or reuse a content-addressed proxy file.
*/
protected function putProxyFile(Ast $ast, string $className): string
{
+ [$sourceFilePath, $sourceCode] = $this->readSource($className, $this->classMap[$className]);
+
$proxyFilePath = $this->getProxyFilePath($className);
- $sourceFilePath = $this->classMap[$className];
- $modified = true;
- if (file_exists($proxyFilePath)) {
- $modified = $this->isModified($className, $sourceFilePath, $proxyFilePath);
- }
+ $fingerprint = $this->fingerprint($className, $sourceFilePath, $sourceCode);
- if ($modified) {
- $code = $ast->proxy($className, $sourceFilePath);
- file_put_contents($proxyFilePath, $code);
+ if (hash_equals($fingerprint, $this->readEmbeddedFingerprint($proxyFilePath) ?? '')) {
+ return $proxyFilePath;
}
- static::$generatedFrom[$className] = $sourceFilePath;
+ $proxyCode = $ast->proxy($className, $sourceFilePath, $sourceCode);
+ $this->filesystem->replace(
+ $proxyFilePath,
+ $this->embedFingerprint($proxyCode, $fingerprint)
+ );
return $proxyFilePath;
}
- /**
- * Determine if the source class has been modified since the proxy was generated.
- */
- protected function isModified(string $className, string $sourceFilePath, ?string $proxyFilePath = null): bool
- {
- $proxyFilePath = $proxyFilePath ?? $this->getProxyFilePath($className);
- if (isset(static::$generatedFrom[$className]) && static::$generatedFrom[$className] !== $sourceFilePath) {
- return true;
- }
-
- $time = $this->filesystem->lastModified($proxyFilePath);
- if ($time >= $this->filesystem->lastModified($sourceFilePath)) {
- return false;
- }
-
- return true;
- }
-
/**
* Get the proxy file path for a class.
*/
protected function getProxyFilePath(string $className): string
{
- return $this->getProxyDir() . str_replace('\\', '_', $className) . '.proxy.php';
+ return rtrim($this->getProxyDir(), '/\\')
+ . DIRECTORY_SEPARATOR
+ . rawurlencode($className)
+ . '.proxy.php';
}
/**
@@ -149,52 +129,195 @@ 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}$/";
- if (preg_match($pattern, $target)) {
- return true;
- }
-
- return false;
+ return preg_match($pattern, $target) === 1;
}
/**
- * Determine which classes in the class map need proxy generation
- * based on registered aspect class rules.
+ * Determine which classes in the class map need proxy generation.
+ *
+ * @param array $reflectionClassMap
+ * @return array
*/
protected function initProxiesByReflectionClassMap(array $reflectionClassMap = []): array
{
- $proxies = [];
- if (! $reflectionClassMap) {
- return $proxies;
+ if ($reflectionClassMap === []) {
+ return [];
}
- $classesAspects = AspectCollector::getClassRules();
- foreach ($classesAspects as $aspect => $rules) {
+
+ $proxies = [];
+
+ foreach (AspectCollector::getClassRules() as $rules) {
foreach ($rules as $rule) {
foreach ($reflectionClassMap as $class => $path) {
- if (! $this->isMatch($rule, $class)) {
- continue;
+ if ($this->isMatch($rule, $class)) {
+ $proxies[$class] = true;
}
- $proxies[$class][] = $aspect;
}
}
}
+
return $proxies;
}
/**
- * Flush generated proxy source tracking.
+ * Read one authoritative canonical source file.
*
- * Tests only. Do not register this with the global after-test subscriber:
- * proxy files can persist in a worker's runtime skeleton between tests,
- * and clearing this map would hide source-path changes behind mtime checks.
+ * @return array{string, string}
*/
- public static function flushState(): void
+ private function readSource(string $className, string $sourceFilePath): array
{
- static::$generatedFrom = [];
+ $canonicalPath = realpath($sourceFilePath);
+
+ if ($canonicalPath === false) {
+ throw new InvalidDefinitionException(
+ "Unable to generate an AOP proxy for [{$className}]: source file [{$sourceFilePath}] does not exist."
+ );
+ }
+
+ try {
+ return [$canonicalPath, $this->filesystem->get($canonicalPath)];
+ } catch (Throwable $exception) {
+ throw new InvalidDefinitionException(
+ "Unable to generate an AOP proxy for [{$className}]: source file [{$canonicalPath}] could not be read.",
+ previous: $exception
+ );
+ }
+ }
+
+ /**
+ * Build the complete proxy-generation fingerprint.
+ */
+ private function fingerprint(string $className, string $sourceFilePath, string $sourceCode): string
+ {
+ return hash('sha256', serialize([
+ 'common' => $this->commonFingerprint ??= $this->buildCommonFingerprint(),
+ 'class' => $className,
+ 'source_path' => $sourceFilePath,
+ 'source_code' => $sourceCode,
+ ]));
+ }
+
+ /**
+ * Fingerprint every worker-stable input shared by generated proxies.
+ */
+ private function buildCommonFingerprint(): string
+ {
+ return hash('sha256', serialize([
+ 'aspect_rules' => AspectCollector::getRules(),
+ 'visitors' => $this->visitorFingerprints(),
+ 'aop_source' => $this->aopSourceFingerprint(),
+ 'php_parser' => [
+ 'version' => InstalledVersions::getPrettyVersion('nikic/php-parser'),
+ 'reference' => InstalledVersions::getReference('nikic/php-parser'),
+ ],
+ 'php_version_id' => PHP_VERSION_ID,
+ ]));
+ }
+
+ /**
+ * Fingerprint visitors in their effective traversal order.
+ *
+ * @return array
+ */
+ private function visitorFingerprints(): array
+ {
+ $fingerprints = [];
+
+ foreach (AstVisitorRegistry::getQueue()->toArray() as $visitor) {
+ $reflection = ClassMetadataCache::reflectClass($visitor);
+ $path = $reflection->getFileName();
+
+ if ($path === false || ($path = realpath($path)) === false) {
+ throw new InvalidDefinitionException(
+ "Unable to fingerprint AOP visitor [{$visitor}]: its source file is unavailable."
+ );
+ }
+
+ $fingerprints[] = [
+ 'class' => $visitor,
+ 'path' => $path,
+ 'content' => $this->filesystem->get($path),
+ ];
+ }
+
+ return $fingerprints;
+ }
+
+ /**
+ * Fingerprint the framework-owned AOP generator implementation.
+ */
+ 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);
+ }
+
+ return hash('sha256', serialize($sources));
+ }
+
+ /**
+ * Read only the fingerprint header from an existing proxy.
+ */
+ private function readEmbeddedFingerprint(string $proxyFilePath): ?string
+ {
+ $handle = @fopen($proxyFilePath, 'rb');
+
+ if ($handle === false) {
+ return null;
+ }
+
+ try {
+ $openingTag = fgets($handle);
+ $header = fgets($handle);
+ } finally {
+ fclose($handle);
+ }
+
+ if ($openingTag !== "className;
- $methodName = $proceedingJoinPoint->methodName;
- if (! AspectManager::has($className, $methodName)) {
- AspectManager::set($className, $methodName, []);
- $aspects = array_unique(static::getClassesAspects($className, $methodName));
- $queue = new SplPriorityQueue;
- foreach ($aspects as $aspect) {
- $queue->insert($aspect, AspectCollector::getPriority($aspect));
- }
- while ($queue->valid()) {
- AspectManager::insert($className, $methodName, $queue->current());
- $queue->next();
- }
-
- unset($aspects, $queue);
- }
-
- if (empty(AspectManager::get($className, $methodName))) {
- return $proceedingJoinPoint->processOriginalMethod();
- }
-
- return static::makePipeline()->via('process')
- ->through(AspectManager::get($className, $methodName))
- ->send($proceedingJoinPoint)
- ->then(function (ProceedingJoinPoint $proceedingJoinPoint) {
- return $proceedingJoinPoint->processOriginalMethod();
- });
- }
-
- /**
- * Create a new AOP pipeline instance.
- *
- * Must be `new` — Pipeline is mutable and auto-singletoning it would
- * share state across concurrent coroutines.
- */
- protected static function makePipeline(): Pipeline
- {
- return new Pipeline(Container::getInstance());
- }
-
- /**
- * Get the aspects that target the given class method via class rules.
- */
- protected static function getClassesAspects(string $className, string $method): array
- {
- $aspects = AspectCollector::getClassRules();
- $matchedAspects = [];
- foreach ($aspects as $aspect => $rules) {
- foreach ($rules as $rule) {
- if (Aspect::isMatch($className, $method, $rule)) {
- $matchedAspects[] = $aspect;
- break;
- }
- }
- }
- return $matchedAspects;
- }
-}
diff --git a/src/di/src/Aop/VisitorMetadata.php b/src/di/src/Aop/VisitorMetadata.php
index e24b99897..0593a4a61 100644
--- a/src/di/src/Aop/VisitorMetadata.php
+++ b/src/di/src/Aop/VisitorMetadata.php
@@ -4,22 +4,16 @@
namespace Hypervel\Di\Aop;
-use PhpParser\Node;
-
class VisitorMetadata
{
- public bool $hasConstructor = false;
-
- public ?Node\Stmt\ClassMethod $constructorNode = null;
-
- public ?bool $hasExtends = null;
-
/**
* The class name of \PhpParser\Node\Stmt\ClassLike.
*/
public ?string $classLike = null;
- public function __construct(public string $className)
- {
+ public function __construct(
+ public string $className,
+ public string $sourceFilePath = ''
+ ) {
}
}
diff --git a/src/events/README.md b/src/events/README.md
index 7542fe892..62d7bcdf8 100644
--- a/src/events/README.md
+++ b/src/events/README.md
@@ -2,3 +2,9 @@ Events for Hypervel
===
[](https://deepwiki.com/hypervel/events)
+
+Ported from: https://github.com/laravel/framework
+
+## Differences From Laravel
+
+Hypervel queued listeners accept the current array payload produced by the event dispatcher. Laravel's pre-2017 serialized-string compatibility path is intentionally omitted because Hypervel 0.4 has no legacy listener payloads and every supported producer uses the array form.
diff --git a/src/events/src/CallQueuedListener.php b/src/events/src/CallQueuedListener.php
index 129476546..c898f848c 100644
--- a/src/events/src/CallQueuedListener.php
+++ b/src/events/src/CallQueuedListener.php
@@ -13,6 +13,7 @@
use Hypervel\Contracts\Queue\ShouldQueue;
use Hypervel\Queue\InteractsWithQueue;
use Throwable;
+use UnitEnum;
#[AllowDynamicProperties]
class CallQueuedListener implements ShouldQueue
@@ -35,7 +36,7 @@ class CallQueuedListener implements ShouldQueue
/**
* The data to be passed to the listener.
*/
- public array|string $data;
+ public array $data;
/**
* The number of times the job may be attempted.
@@ -50,7 +51,7 @@ class CallQueuedListener implements ShouldQueue
/**
* The number of seconds to wait before retrying a job that encountered an uncaught exception.
*/
- public ?int $backoff = null;
+ public array|int|null $backoff = null;
/**
* The timestamp indicating when the job should timeout.
@@ -102,7 +103,7 @@ class CallQueuedListener implements ShouldQueue
*
* @param class-string $class
*/
- public function __construct(string $class, string $method, array|string $data)
+ public function __construct(string $class, string $method, array $data)
{
$this->data = $data;
$this->class = $class;
@@ -114,8 +115,6 @@ public function __construct(string $class, string $method, array|string $data)
*/
public function handle(Container $container): void
{
- $this->prepareData();
-
$handler = $this->setJobInstanceIfNecessary(
$this->job,
$container->make($this->class)
@@ -167,8 +166,6 @@ public function uniqueVia(): ?Cache
return null;
}
- $this->prepareData();
-
return $listener->uniqueVia(...array_values($this->data));
}
@@ -178,6 +175,9 @@ public function uniqueVia(): ?Cache
protected function setJobInstanceIfNecessary(Job $job, object $instance): object
{
if (in_array(InteractsWithQueue::class, class_uses_recursive($instance))) {
+ // Container resolution may return a worker-shared listener. Clone the
+ // configured instance before injecting the job owned by this execution.
+ $instance = clone $instance;
$instance->setJob($job);
}
@@ -191,8 +191,6 @@ protected function setJobInstanceIfNecessary(Job $job, object $instance): object
*/
public function failed(Throwable $e): void
{
- $this->prepareData();
-
$handler = Container::getInstance()->make($this->class);
$parameters = array_merge(array_values($this->data), [$e]);
@@ -202,15 +200,8 @@ public function failed(Throwable $e): void
}
}
- /**
- * Unserialize the data if needed.
- */
- protected function prepareData(): void
- {
- if (is_string($this->data)) {
- $this->data = unserialize($this->data);
- }
- }
+ // Current queue producers always pass arrays. Legacy serialized-string
+ // listener payloads are intentionally unsupported in Hypervel 0.4.
/**
* Get the display name for the queued job.
@@ -226,7 +217,7 @@ public function displayName(): string
public function __clone(): void
{
$this->data = array_map(function ($data) {
- return is_object($data) ? clone $data : $data;
+ return is_object($data) && ! $data instanceof UnitEnum ? clone $data : $data;
}, $this->data);
}
}
diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php
index 907be96d8..ce8fa535b 100755
--- a/src/events/src/Dispatcher.php
+++ b/src/events/src/Dispatcher.php
@@ -42,6 +42,7 @@
use Hypervel\Support\Traits\ReflectsClosures;
use ReflectionClass;
use ReflectionException;
+use UnitEnum;
use function Hypervel\Support\enum_value;
@@ -80,14 +81,14 @@ class Dispatcher implements DispatcherContract
/**
* The registered event listeners.
*
- * @var array
+ * @var array>
*/
protected array $listeners = [];
/**
* The wildcard listeners.
*
- * @var array>
+ * @var array>
*/
protected array $wildcards = [];
@@ -124,14 +125,14 @@ class Dispatcher implements DispatcherContract
/**
* The registered event observers.
*
- * @var array>
+ * @var array>
*/
protected array $observers = [];
/**
* The wildcard observers.
*
- * @var array>
+ * @var array>
*/
protected array $observerWildcards = [];
@@ -753,6 +754,7 @@ protected function createClassCallable(array|string $listener): callable
}
return $this->handlerShouldBeDispatchedAfterDatabaseTransactions($listener)
+ && ! in_array($method, ['creating', 'updating', 'saving', 'deleting', 'restoring', 'forceDeleting'])
? $this->createCallbackForListenerRunningAfterCommits($listener, $method)
: [$listener, $method];
}
@@ -790,7 +792,7 @@ protected function createQueuedHandlerCallable(string $class, string $method): C
{
return function () use ($class, $method) {
$arguments = array_map(function ($a) {
- return is_object($a) ? clone $a : $a;
+ return is_object($a) && ! $a instanceof UnitEnum ? clone $a : $a;
}, func_get_args());
if ($this->handlerWantsToBeQueued($class, $arguments)) {
diff --git a/src/events/src/QueuedClosure.php b/src/events/src/QueuedClosure.php
index 0ebb2733d..788971078 100644
--- a/src/events/src/QueuedClosure.php
+++ b/src/events/src/QueuedClosure.php
@@ -33,12 +33,14 @@ class QueuedClosure
/**
* The job "group" the job should be sent to.
*/
- public ?string $messageGroup = null;
+ public int|string|null $messageGroup = null;
/**
* The job deduplicator callback the job should use to generate the deduplication ID.
+ *
+ * @var null|callable
*/
- public ?SerializableClosure $deduplicator = null;
+ public mixed $deduplicator = null;
/**
* The number of seconds before the job should be made available.
@@ -141,7 +143,7 @@ public function resolve(): Closure
->onQueue($this->queue)
->delay($this->delay)
->onGroup($this->messageGroup)
- ->withDeduplicator($this->deduplicator?->getClosure());
+ ->withDeduplicator($this->deduplicator);
};
}
}
diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php
index 92c7415b0..d2e38cd72 100644
--- a/src/filesystem/src/Filesystem.php
+++ b/src/filesystem/src/Filesystem.php
@@ -457,6 +457,8 @@ public function lastModified(string $path): int
/**
* Determine if the given path is a directory.
+ *
+ * @phpstan-impure
*/
public function isDirectory(string $directory): bool
{
@@ -563,8 +565,12 @@ public function allDirectories(array|string $directory): array
*/
public function ensureDirectoryExists(string $path, int $mode = 0755, bool $recursive = true): void
{
- if (! $this->isDirectory($path)) {
- $this->makeDirectory($path, $mode, $recursive);
+ if ($this->isDirectory($path)) {
+ return;
+ }
+
+ if (! $this->makeDirectory($path, $mode, $recursive, true) && ! $this->isDirectory($path)) {
+ throw new RuntimeException("Unable to create directory [{$path}].");
}
}
diff --git a/src/foundation/src/Bus/PendingChain.php b/src/foundation/src/Bus/PendingChain.php
index 12a4502ea..eda71caa6 100644
--- a/src/foundation/src/Bus/PendingChain.php
+++ b/src/foundation/src/Bus/PendingChain.php
@@ -146,10 +146,10 @@ public function catchCallbacks(): array
/**
* Dispatch the job chain.
*/
- public function dispatch(): mixed
+ public function dispatch(mixed ...$arguments): mixed
{
if (is_string($this->job)) {
- $firstJob = new $this->job(...func_get_args());
+ $firstJob = new $this->job(...$arguments);
} elseif ($this->job instanceof Closure) {
$firstJob = CallQueuedClosure::create($this->job);
} else {
diff --git a/src/foundation/src/Bus/PendingDispatch.php b/src/foundation/src/Bus/PendingDispatch.php
index d3a7d4d56..2db5fa658 100644
--- a/src/foundation/src/Bus/PendingDispatch.php
+++ b/src/foundation/src/Bus/PendingDispatch.php
@@ -4,7 +4,6 @@
namespace Hypervel\Foundation\Bus;
-use Closure;
use DateInterval;
use DateTimeInterface;
use Hypervel\Bus\DebounceLock;
@@ -65,7 +64,7 @@ public function onQueue(UnitEnum|string|null $queue): static
*
* This feature is only supported by some queues, such as Amazon SQS.
*/
- public function onGroup(UnitEnum|string|null $group): static
+ public function onGroup(array|UnitEnum|string|int|null $group): static
{
if (! is_null($group)) {
$this->job->onGroup($group);
@@ -79,7 +78,7 @@ public function onGroup(UnitEnum|string|null $group): static
*
* This feature is only supported by some queues, such as Amazon SQS FIFO.
*/
- public function withDeduplicator(?Closure $deduplicator): static
+ public function withDeduplicator(array|callable|null $deduplicator): static
{
$this->job->withDeduplicator($deduplicator);
diff --git a/src/foundation/src/Events/Dispatchable.php b/src/foundation/src/Events/Dispatchable.php
index c3d602704..fb9b54f16 100644
--- a/src/foundation/src/Events/Dispatchable.php
+++ b/src/foundation/src/Events/Dispatchable.php
@@ -11,9 +11,9 @@ trait Dispatchable
/**
* Dispatch the event with the given arguments.
*/
- public static function dispatch(): mixed
+ public static function dispatch(mixed ...$arguments): mixed
{
- return event(new static(...func_get_args()));
+ return event(new static(...$arguments));
}
/**
@@ -35,8 +35,8 @@ public static function dispatchUnless(bool $boolean, mixed ...$arguments): mixed
/**
* Broadcast the event with the given arguments.
*/
- public static function broadcast(): PendingBroadcast
+ public static function broadcast(mixed ...$arguments): PendingBroadcast
{
- return broadcast(new static(...func_get_args()));
+ return broadcast(new static(...$arguments));
}
}
diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAop.php b/src/foundation/src/Testing/Concerns/InteractsWithAop.php
index 7975cb2c4..9557a93bc 100644
--- a/src/foundation/src/Testing/Concerns/InteractsWithAop.php
+++ b/src/foundation/src/Testing/Concerns/InteractsWithAop.php
@@ -4,12 +4,8 @@
namespace Hypervel\Foundation\Testing\Concerns;
-use Hypervel\Container\Container;
-use Hypervel\Di\Aop\Aspect;
-use Hypervel\Di\Aop\AspectCollector;
-use Hypervel\Di\Aop\Pipeline;
-use Hypervel\Di\Aop\ProceedingJoinPoint;
-use Hypervel\Support\SplPriorityQueue;
+use Hypervel\Di\Aop\ProxyDispatcher;
+use Hypervel\Di\Aop\ProxyMarker;
use LogicException;
use ReflectionMethod;
@@ -22,9 +18,8 @@
* trait is for tests where the target is not already proxied, or where the
* goal is to exercise aspect pipeline behavior directly.
*
- * callWithAspects() builds a ProceedingJoinPoint for the target method and runs
- * it through the same aspect resolution and pipeline execution flow used by
- * generated proxies before invoking the original method via reflection.
+ * callWithAspects() invokes the same dispatcher used by generated proxies
+ * before calling the original method via reflection.
*/
trait InteractsWithAop
{
@@ -42,7 +37,7 @@ trait InteractsWithAop
*/
protected function isAopProxied(object $instance): bool
{
- return method_exists($instance, '__proxyCall');
+ return in_array(ProxyMarker::class, class_uses_recursive($instance), true);
}
/**
@@ -70,27 +65,12 @@ protected function callWithAspects(object $instance, string $method, array $argu
$reflectionMethod = new ReflectionMethod($className, $method);
- $formattedArguments = $this->buildAopArguments($reflectionMethod, $arguments);
- $originalMethod = $reflectionMethod->getClosure($instance);
-
- $joinPoint = new ProceedingJoinPoint(
- $originalMethod,
+ return ProxyDispatcher::dispatch(
$className,
$method,
- $formattedArguments
+ $this->buildAopArguments($reflectionMethod, $arguments),
+ $reflectionMethod->getClosure($instance)
);
-
- $aspects = $this->resolveMatchingAspects($className, $method);
-
- if (empty($aspects)) {
- return $joinPoint->processOriginalMethod();
- }
-
- return (new Pipeline(Container::getInstance()))
- ->via('process')
- ->through($aspects)
- ->send($joinPoint)
- ->then(fn (ProceedingJoinPoint $point) => $point->processOriginalMethod());
}
/**
@@ -128,41 +108,4 @@ private function buildAopArguments(ReflectionMethod $method, array $provided): a
'variadic' => $variadic,
];
}
-
- /**
- * Resolve the aspects that match the given class and method, sorted by priority.
- *
- * @return array Aspect class names in priority order
- */
- private function resolveMatchingAspects(string $className, string $method): array
- {
- $allAspects = AspectCollector::getClassRules();
- $matched = [];
-
- foreach ($allAspects as $aspect => $rules) {
- foreach ($rules as $rule) {
- if (Aspect::isMatch($className, $method, $rule)) {
- $matched[] = $aspect;
- break;
- }
- }
- }
-
- if (empty($matched)) {
- return [];
- }
-
- $queue = new SplPriorityQueue;
- foreach ($matched as $aspect) {
- $queue->insert($aspect, AspectCollector::getPriority($aspect));
- }
-
- $sorted = [];
- while ($queue->valid()) {
- $sorted[] = $queue->current();
- $queue->next();
- }
-
- return $sorted;
- }
}
diff --git a/src/queue/src/Attributes/Backoff.php b/src/queue/src/Attributes/Backoff.php
index ac825712a..0a75fd14f 100644
--- a/src/queue/src/Attributes/Backoff.php
+++ b/src/queue/src/Attributes/Backoff.php
@@ -9,13 +9,22 @@
#[Attribute(Attribute::TARGET_CLASS)]
readonly class Backoff
{
+ /**
+ * The backoff values.
+ *
+ * @var array|int
+ */
+ public array|int $backoff;
+
/**
* Create a new attribute instance.
*
- * @param array|int $backoff
+ * @param array|int ...$backoff Seconds to wait before retrying the job.
*/
- public function __construct(
- public array|int $backoff,
- ) {
+ public function __construct(array|int ...$backoff)
+ {
+ $backoff = array_values($backoff);
+
+ $this->backoff = count($backoff) === 1 ? $backoff[0] : $backoff;
}
}
diff --git a/src/queue/src/CallQueuedHandler.php b/src/queue/src/CallQueuedHandler.php
index e7d019b45..803dfcadb 100644
--- a/src/queue/src/CallQueuedHandler.php
+++ b/src/queue/src/CallQueuedHandler.php
@@ -145,8 +145,11 @@ protected function resolveHandler(Job $job, mixed $command): mixed
{
$handler = $this->dispatcher->getCommandHandler($command) ?: null;
- if ($handler) {
- $this->setJobInstanceIfNecessary($job, $handler);
+ if ($handler && in_array(InteractsWithQueue::class, class_uses_recursive($handler))) {
+ // Mapped handlers may be worker-shared container instances. Clone the
+ // configured handler before injecting the job owned by this execution.
+ $handler = clone $handler;
+ $handler->setJob($job);
}
return $handler;
diff --git a/src/queue/src/Middleware/WithoutOverlapping.php b/src/queue/src/Middleware/WithoutOverlapping.php
index d8b6c0d8b..923dfebf5 100644
--- a/src/queue/src/Middleware/WithoutOverlapping.php
+++ b/src/queue/src/Middleware/WithoutOverlapping.php
@@ -133,7 +133,7 @@ public function getLockKey(mixed $job): string
}
$jobName = method_exists($job, 'displayName')
- ? $job->displayName()
+ ? hash('xxh128', $job->displayName())
: get_class($job);
return $this->prefix . $jobName . ':' . $this->key;
diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php
index 7027538d1..0ee03df86 100644
--- a/src/support/src/ServiceProvider.php
+++ b/src/support/src/ServiceProvider.php
@@ -14,7 +14,6 @@
use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Di\ClassMap\ClassMapManager;
use Hypervel\View\Compilers\CompilerInterface;
-use ReflectionClass;
use ReflectionProperty;
abstract class ServiceProvider
@@ -572,7 +571,7 @@ protected function aspects(string|array $aspects): void
$aspects = is_array($aspects) ? $aspects : func_get_args();
foreach ($aspects as $aspect) {
- $reflectionClass = new ReflectionClass($aspect);
+ $reflectionClass = ClassMetadataCache::reflectClass($aspect);
$properties = $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC);
$classes = [];
diff --git a/src/support/src/Testing/Fakes/PendingChainFake.php b/src/support/src/Testing/Fakes/PendingChainFake.php
index d0bf0cf50..1dd54c86d 100644
--- a/src/support/src/Testing/Fakes/PendingChainFake.php
+++ b/src/support/src/Testing/Fakes/PendingChainFake.php
@@ -25,10 +25,10 @@ public function __construct(
/**
* Dispatch the job with the given arguments.
*/
- public function dispatch(): mixed
+ public function dispatch(mixed ...$arguments): mixed
{
if (is_string($this->job)) {
- $firstJob = new $this->job(...func_get_args());
+ $firstJob = new $this->job(...$arguments);
} elseif ($this->job instanceof Closure) {
$firstJob = CallQueuedClosure::create($this->job);
} else {
diff --git a/tests/Broadcasting/BroadcastEventTest.php b/tests/Broadcasting/BroadcastEventTest.php
index 50b4e4876..9aae51f88 100644
--- a/tests/Broadcasting/BroadcastEventTest.php
+++ b/tests/Broadcasting/BroadcastEventTest.php
@@ -9,8 +9,9 @@
use Hypervel\Broadcasting\InteractsWithBroadcasting;
use Hypervel\Contracts\Broadcasting\Broadcaster;
use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory;
+use Hypervel\Queue\Attributes\Backoff;
+use Hypervel\Tests\TestCase;
use Mockery as m;
-use PHPUnit\Framework\TestCase;
use Throwable;
class BroadcastEventTest extends TestCase
@@ -133,6 +134,20 @@ public function testDeleteWhenMissingModelsDefaultsToTrue()
$this->assertTrue($job->deleteWhenMissingModels);
}
+
+ public function testArrayBackoffIsReadFromTheUnderlyingEvent(): void
+ {
+ $job = new BroadcastEvent(new TestBroadcastEventWithArrayBackoff);
+
+ $this->assertSame([1, 5, 10], $job->backoff);
+ }
+
+ public function testVariadicBackoffIsReadFromTheUnderlyingEvent(): void
+ {
+ $job = new BroadcastEvent(new TestBroadcastEventWithVariadicBackoff);
+
+ $this->assertSame([1, 5, 10], $job->backoff);
+ }
}
class TestBroadcastEvent
@@ -206,3 +221,13 @@ public function broadcastOn()
];
}
}
+
+#[Backoff([1, 5, 10])]
+class TestBroadcastEventWithArrayBackoff extends TestBroadcastEvent
+{
+}
+
+#[Backoff(1, 5, 10)]
+class TestBroadcastEventWithVariadicBackoff extends TestBroadcastEvent
+{
+}
diff --git a/tests/Bus/BusPendingDispatchTest.php b/tests/Bus/BusPendingDispatchTest.php
index f6cf2c792..04e986027 100644
--- a/tests/Bus/BusPendingDispatchTest.php
+++ b/tests/Bus/BusPendingDispatchTest.php
@@ -65,6 +65,14 @@ public function testOnGroup()
$this->pendingDispatch->onGroup('test-group');
}
+ public function testOnGroupForwardsAnArray(): void
+ {
+ $groups = ['first', 'second'];
+
+ $this->job->shouldReceive('onGroup')->once()->with($groups);
+ $this->pendingDispatch->onGroup($groups);
+ }
+
public function testWithDeduplicator()
{
$deduplicator = fn () => 'id';
@@ -72,6 +80,19 @@ public function testWithDeduplicator()
$this->pendingDispatch->withDeduplicator($deduplicator);
}
+ public function testWithDeduplicatorForwardsAnArrayCallable(): void
+ {
+ $deduplicator = [$this, 'resolveDeduplicationId'];
+
+ $this->job->shouldReceive('withDeduplicator')->once()->with($deduplicator);
+ $this->pendingDispatch->withDeduplicator($deduplicator);
+ }
+
+ public function resolveDeduplicationId(): string
+ {
+ return 'id';
+ }
+
public function testAllOnConnection()
{
$this->job->shouldReceive('allOnConnection')->once()->with('test-connection');
diff --git a/tests/Context/ContextEnumTest.php b/tests/Context/ContextEnumTest.php
index 0b500d578..b952be3d8 100644
--- a/tests/Context/ContextEnumTest.php
+++ b/tests/Context/ContextEnumTest.php
@@ -28,28 +28,28 @@ enum ContextKeyUnitEnum
class ContextEnumTest extends TestCase
{
- public function testSetAndGetWithBackedEnum()
+ public function testSetAndGetWithBackedEnum(): void
{
CoroutineContext::set(ContextKeyBackedEnum::CurrentUser, 'user-123');
$this->assertSame('user-123', CoroutineContext::get(ContextKeyBackedEnum::CurrentUser));
}
- public function testSetAndGetWithUnitEnum()
+ public function testSetAndGetWithUnitEnum(): void
{
CoroutineContext::set(ContextKeyUnitEnum::Locale, 'en-US');
$this->assertSame('en-US', CoroutineContext::get(ContextKeyUnitEnum::Locale));
}
- public function testSetAndGetWithIntBackedEnum()
+ public function testSetAndGetWithIntBackedEnum(): void
{
CoroutineContext::set(ContextKeyIntBackedEnum::UserId, 'user-123');
$this->assertSame('user-123', CoroutineContext::get(ContextKeyIntBackedEnum::UserId));
}
- public function testHasWithBackedEnum()
+ public function testHasWithBackedEnum(): void
{
$this->assertFalse(CoroutineContext::has(ContextKeyBackedEnum::CurrentUser));
@@ -58,7 +58,7 @@ public function testHasWithBackedEnum()
$this->assertTrue(CoroutineContext::has(ContextKeyBackedEnum::CurrentUser));
}
- public function testHasWithUnitEnum()
+ public function testHasWithUnitEnum(): void
{
$this->assertFalse(CoroutineContext::has(ContextKeyUnitEnum::Locale));
@@ -67,7 +67,7 @@ public function testHasWithUnitEnum()
$this->assertTrue(CoroutineContext::has(ContextKeyUnitEnum::Locale));
}
- public function testForgetWithBackedEnum()
+ public function testForgetWithBackedEnum(): void
{
CoroutineContext::set(ContextKeyBackedEnum::CurrentUser, 'user-123');
$this->assertTrue(CoroutineContext::has(ContextKeyBackedEnum::CurrentUser));
@@ -77,7 +77,7 @@ public function testForgetWithBackedEnum()
$this->assertFalse(CoroutineContext::has(ContextKeyBackedEnum::CurrentUser));
}
- public function testForgetWithUnitEnum()
+ public function testForgetWithUnitEnum(): void
{
CoroutineContext::set(ContextKeyUnitEnum::Locale, 'en-US');
$this->assertTrue(CoroutineContext::has(ContextKeyUnitEnum::Locale));
@@ -87,7 +87,7 @@ public function testForgetWithUnitEnum()
$this->assertFalse(CoroutineContext::has(ContextKeyUnitEnum::Locale));
}
- public function testOverrideWithBackedEnum()
+ public function testOverrideWithBackedEnum(): void
{
CoroutineContext::set(ContextKeyBackedEnum::CurrentUser, 'user-123');
@@ -97,7 +97,7 @@ public function testOverrideWithBackedEnum()
$this->assertSame('user-123-modified', CoroutineContext::get(ContextKeyBackedEnum::CurrentUser));
}
- public function testOverrideWithUnitEnum()
+ public function testOverrideWithUnitEnum(): void
{
CoroutineContext::set(ContextKeyUnitEnum::Locale, 'en');
@@ -107,7 +107,7 @@ public function testOverrideWithUnitEnum()
$this->assertSame('en-US', CoroutineContext::get(ContextKeyUnitEnum::Locale));
}
- public function testGetOrSetWithBackedEnum()
+ public function testGetOrSetWithBackedEnum(): void
{
// First call should set and return the value
$result = CoroutineContext::getOrSet(ContextKeyBackedEnum::RequestId, 'req-001');
@@ -118,7 +118,7 @@ public function testGetOrSetWithBackedEnum()
$this->assertSame('req-001', $result);
}
- public function testGetOrSetWithUnitEnum()
+ public function testGetOrSetWithUnitEnum(): void
{
$result = CoroutineContext::getOrSet(ContextKeyUnitEnum::Theme, 'dark');
$this->assertSame('dark', $result);
@@ -127,7 +127,7 @@ public function testGetOrSetWithUnitEnum()
$this->assertSame('dark', $result);
}
- public function testGetOrSetWithClosure()
+ public function testGetOrSetWithClosure(): void
{
$callCount = 0;
$callback = function () use (&$callCount) {
@@ -145,7 +145,7 @@ public function testGetOrSetWithClosure()
$this->assertSame(1, $callCount);
}
- public function testSetManyWithEnumKeys()
+ public function testSetManyWithEnumKeys(): void
{
CoroutineContext::setMany([
ContextKeyBackedEnum::CurrentUser->value => 'user-123',
@@ -156,7 +156,16 @@ public function testSetManyWithEnumKeys()
$this->assertSame('en-US', CoroutineContext::get(ContextKeyUnitEnum::Locale));
}
- public function testBackedEnumAndStringInteroperability()
+ public function testSetManyWithIntegerBackedEnumKey(): void
+ {
+ CoroutineContext::setMany([
+ ContextKeyIntBackedEnum::UserId->value => 'user-123',
+ ]);
+
+ $this->assertSame('user-123', CoroutineContext::get(ContextKeyIntBackedEnum::UserId));
+ }
+
+ public function testBackedEnumAndStringInteroperability(): void
{
// Set with enum
CoroutineContext::set(ContextKeyBackedEnum::CurrentUser, 'user-123');
@@ -171,7 +180,7 @@ public function testBackedEnumAndStringInteroperability()
$this->assertSame('req-456', CoroutineContext::get(ContextKeyBackedEnum::RequestId));
}
- public function testUnitEnumAndStringInteroperability()
+ public function testUnitEnumAndStringInteroperability(): void
{
// Set with enum
CoroutineContext::set(ContextKeyUnitEnum::Locale, 'en-US');
@@ -186,21 +195,21 @@ public function testUnitEnumAndStringInteroperability()
$this->assertSame('dark', CoroutineContext::get(ContextKeyUnitEnum::Theme));
}
- public function testGetWithDefaultAndBackedEnum()
+ public function testGetWithDefaultAndBackedEnum(): void
{
$result = CoroutineContext::get(ContextKeyBackedEnum::CurrentUser, 'default-user');
$this->assertSame('default-user', $result);
}
- public function testGetWithDefaultAndUnitEnum()
+ public function testGetWithDefaultAndUnitEnum(): void
{
$result = CoroutineContext::get(ContextKeyUnitEnum::Locale, 'en');
$this->assertSame('en', $result);
}
- public function testMultipleEnumKeysCanCoexist()
+ public function testMultipleEnumKeysCanCoexist(): void
{
CoroutineContext::set(ContextKeyBackedEnum::CurrentUser, 'user-123');
CoroutineContext::set(ContextKeyBackedEnum::RequestId, 'req-456');
diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php
index 47c9be3b1..d7efbc1aa 100644
--- a/tests/Context/ContextTest.php
+++ b/tests/Context/ContextTest.php
@@ -4,14 +4,18 @@
namespace Hypervel\Tests\Context;
+use ArrayObject;
use Hypervel\Context\CoroutineContext;
use Hypervel\Context\RequestContext;
use Hypervel\Context\ResponseContext;
use Hypervel\Coroutine\Coroutine;
+use Hypervel\Engine\Coroutine as EngineCoroutine;
+use Hypervel\Engine\Exceptions\CoroutineDestroyedException;
use Hypervel\Http\Request;
use Hypervel\Http\Response;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use Swoole\Event;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use function Hypervel\Coroutine\run;
@@ -20,7 +24,7 @@ class ContextTest extends TestCase
{
protected bool $runTestsInCoroutine = false;
- public function testSetMany()
+ public function testSetMany(): void
{
$values = [
'key1' => 'value1',
@@ -36,70 +40,73 @@ public function testSetMany()
}
}
- /**
- * @covers ::copyFromNonCoroutine
- */
- public function testCopyFromNonCoroutineCopiesAllKeys()
+ public function testCopyFromNonCoroutineCopiesAllKeys(): void
{
CoroutineContext::set('foo', 'foo');
CoroutineContext::set('bar', 'bar');
+ $copied = [];
- run(function () {
- Coroutine::create(function () {
+ run(static function () use (&$copied): void {
+ Coroutine::create(static function () use (&$copied): void {
CoroutineContext::copyFromNonCoroutine();
- $this->assertSame('foo', CoroutineContext::get('foo'));
- $this->assertSame('bar', CoroutineContext::get('bar'));
+ $copied = [
+ 'foo' => CoroutineContext::get('foo'),
+ 'bar' => CoroutineContext::get('bar'),
+ ];
});
});
+
+ $this->assertSame(['foo' => 'foo', 'bar' => 'bar'], $copied);
}
- /**
- * @covers ::copyFromNonCoroutine
- */
- public function testCopyFromNonCoroutinePreservesExistingCoroutineValues()
+ public function testCopyFromNonCoroutinePreservesExistingCoroutineValues(): void
{
CoroutineContext::set('from_non_co', 'copied');
+ $copied = [];
- run(function () {
- Coroutine::create(function () {
- // Set a value in the coroutine before copying
+ run(static function () use (&$copied): void {
+ Coroutine::create(static function () use (&$copied): void {
CoroutineContext::set('existing', 'should_survive');
-
CoroutineContext::copyFromNonCoroutine();
-
- // Copied value is present
- $this->assertSame('copied', CoroutineContext::get('from_non_co'));
- // Pre-existing coroutine value is preserved
- $this->assertSame('should_survive', CoroutineContext::get('existing'));
+ $copied = [
+ 'from_non_co' => CoroutineContext::get('from_non_co'),
+ 'existing' => CoroutineContext::get('existing'),
+ ];
});
});
+
+ $this->assertSame([
+ 'from_non_co' => 'copied',
+ 'existing' => 'should_survive',
+ ], $copied);
}
- /**
- * @covers ::copyFromNonCoroutine
- */
- public function testCopyFromNonCoroutineWithSelectiveKeysPreservesExisting()
+ public function testCopyFromNonCoroutineWithSelectiveKeysPreservesExisting(): void
{
CoroutineContext::set('wanted', 'yes');
CoroutineContext::set('unwanted', 'no');
+ $copied = [];
- run(function () {
- Coroutine::create(function () {
+ run(static function () use (&$copied): void {
+ Coroutine::create(static function () use (&$copied): void {
CoroutineContext::set('existing', 'kept');
-
CoroutineContext::copyFromNonCoroutine(['wanted']);
-
- $this->assertSame('yes', CoroutineContext::get('wanted'));
- $this->assertNull(CoroutineContext::get('unwanted'));
- $this->assertSame('kept', CoroutineContext::get('existing'));
+ $copied = [
+ 'wanted' => CoroutineContext::get('wanted'),
+ 'unwanted' => CoroutineContext::get('unwanted'),
+ 'existing' => CoroutineContext::get('existing'),
+ ];
});
});
+
+ $this->assertSame([
+ 'wanted' => 'yes',
+ 'unwanted' => null,
+ 'existing' => 'kept',
+ ], $copied);
}
- /**
- * @covers ::flush
- */
- public function testFlush()
+ public function testFlush(): void
{
CoroutineContext::set('key1', 'value1');
CoroutineContext::set('key2', 'value2');
@@ -113,7 +120,100 @@ public function testFlush()
$this->assertFalse(CoroutineContext::has('key2'));
}
- public function testOverride()
+ public function testExplicitCoroutineIdTargetsLiveContextFromOutsideCoroutine(): void
+ {
+ CoroutineContext::set('shared', 'fallback');
+
+ $coroutineId = Coroutine::create(static function (): void {
+ CoroutineContext::set('shared', 'child');
+ CoroutineContext::set('child-only', 'value');
+ EngineCoroutine::yield();
+ });
+
+ try {
+ $this->assertTrue(CoroutineContext::has('shared', $coroutineId));
+ $this->assertSame('child', CoroutineContext::get('shared', null, $coroutineId));
+
+ $container = CoroutineContext::getContainer($coroutineId);
+ $this->assertInstanceOf(ArrayObject::class, $container);
+ $this->assertSame('value', $container['child-only']);
+
+ CoroutineContext::set('shared', 'updated', $coroutineId);
+ $this->assertSame('updated', CoroutineContext::get('shared', null, $coroutineId));
+ $this->assertSame('fallback', CoroutineContext::getFromNonCoroutine('shared'));
+
+ CoroutineContext::forget('shared', $coroutineId);
+ $this->assertFalse(CoroutineContext::has('shared', $coroutineId));
+ $this->assertSame('fallback', CoroutineContext::getFromNonCoroutine('shared'));
+
+ CoroutineContext::flush($coroutineId);
+ $this->assertSame([], $container->getArrayCopy());
+ $this->assertSame('fallback', CoroutineContext::getFromNonCoroutine('shared'));
+ } finally {
+ EngineCoroutine::resumeById($coroutineId);
+ // Drain explicitly so Swoole does not fall back to its deprecated shutdown wait.
+ Event::wait();
+ }
+ }
+
+ public function testCurrentCoroutineMutationNeverClearsFallbackStorage(): void
+ {
+ CoroutineContext::set('forgotten', 'fallback-forgotten');
+ CoroutineContext::set('flushed', 'fallback-flushed');
+ $observed = [];
+
+ run(static function () use (&$observed): void {
+ CoroutineContext::set('forgotten', 'coroutine-forgotten');
+ CoroutineContext::set('flushed', 'coroutine-flushed');
+
+ CoroutineContext::forget('forgotten');
+ $observed['forgotten'] = CoroutineContext::has('forgotten');
+ $observed['fallback-forgotten'] = CoroutineContext::getFromNonCoroutine('forgotten');
+
+ CoroutineContext::flush();
+ $observed['flushed'] = CoroutineContext::has('flushed');
+ $observed['fallback-flushed'] = CoroutineContext::getFromNonCoroutine('flushed');
+ });
+
+ $this->assertSame([
+ 'forgotten' => false,
+ 'fallback-forgotten' => 'fallback-forgotten',
+ 'flushed' => false,
+ 'fallback-flushed' => 'fallback-flushed',
+ ], $observed);
+ }
+
+ public function testExplicitDestroyedCoroutineIdNeverUsesFallbackStorage(): void
+ {
+ $coroutineId = Coroutine::create(static function (): void {
+ // The coroutine exits before its ID is used as an explicit target.
+ });
+ // Drain explicitly so Swoole does not fall back to its deprecated shutdown wait.
+ Event::wait();
+
+ CoroutineContext::set('shared', 'fallback');
+
+ $this->assertSame('default', CoroutineContext::get('shared', 'default', $coroutineId));
+ $this->assertFalse(CoroutineContext::has('shared', $coroutineId));
+ $this->assertNull(CoroutineContext::getContainer($coroutineId));
+
+ CoroutineContext::forget('shared', $coroutineId);
+ CoroutineContext::flush($coroutineId);
+ CoroutineContext::flush(-1);
+
+ $this->assertSame('fallback', CoroutineContext::getFromNonCoroutine('shared'));
+
+ try {
+ CoroutineContext::set('shared', 'target', $coroutineId);
+ $this->fail('Expected an explicit write to a destroyed coroutine to fail.');
+ } catch (CoroutineDestroyedException $exception) {
+ $this->assertSame("Coroutine #{$coroutineId} has been destroyed.", $exception->getMessage());
+ }
+
+ $this->assertSame('fallback', CoroutineContext::getFromNonCoroutine('shared'));
+ }
+
+ public function testOverride(): void
{
CoroutineContext::set('override.id', 1);
@@ -124,7 +224,7 @@ public function testOverride()
$this->assertSame(2, CoroutineContext::get('override.id'));
}
- public function testGetOrSet()
+ public function testGetOrSet(): void
{
CoroutineContext::set('test.store.id', null);
$this->assertSame(1, CoroutineContext::getOrSet('test.store.id', function () {
@@ -138,7 +238,7 @@ public function testGetOrSet()
$this->assertSame(1, CoroutineContext::getOrSet('test.store.id', 1));
}
- public function testContextForget()
+ public function testContextForget(): void
{
CoroutineContext::set($id = uniqid(), $value = uniqid());
@@ -147,7 +247,7 @@ public function testContextForget()
$this->assertNull(CoroutineContext::get($id));
}
- public function testRequestContext()
+ public function testRequestContext(): void
{
$request = m::mock(Request::class);
RequestContext::set($request);
@@ -159,7 +259,7 @@ public function testRequestContext()
$this->assertSame($req, CoroutineContext::get(Request::class));
}
- public function testResponseContext()
+ public function testResponseContext(): void
{
$response = m::mock(Response::class);
ResponseContext::set($response);
diff --git a/tests/Di/Aop/AspectManagerTest.php b/tests/Di/Aop/AspectManagerTest.php
index 35b6388c5..5e79f1262 100644
--- a/tests/Di/Aop/AspectManagerTest.php
+++ b/tests/Di/Aop/AspectManagerTest.php
@@ -9,12 +9,12 @@
class AspectManagerTest extends TestCase
{
- public function testHasReturnsFalseForUnsetEntry()
+ public function testHasReturnsFalseForUnsetEntry(): void
{
$this->assertFalse(AspectManager::has('Foo', 'bar'));
}
- public function testSetAndGet()
+ public function testSetAndGet(): void
{
AspectManager::set('Foo', 'bar', ['Aspect1', 'Aspect2']);
@@ -22,21 +22,12 @@ public function testSetAndGet()
$this->assertSame(['Aspect1', 'Aspect2'], AspectManager::get('Foo', 'bar'));
}
- public function testGetReturnsEmptyArrayForUnsetEntry()
+ public function testGetReturnsEmptyArrayForUnsetEntry(): void
{
$this->assertSame([], AspectManager::get('Foo', 'bar'));
}
- public function testInsertAppendsToList()
- {
- AspectManager::set('Foo', 'bar', []);
- AspectManager::insert('Foo', 'bar', 'Aspect1');
- AspectManager::insert('Foo', 'bar', 'Aspect2');
-
- $this->assertSame(['Aspect1', 'Aspect2'], AspectManager::get('Foo', 'bar'));
- }
-
- public function testFlushStateRemovesAllEntries()
+ public function testFlushStateRemovesAllEntries(): void
{
AspectManager::set('Foo', 'bar', ['Aspect1']);
AspectManager::set('Baz', 'qux', ['Aspect2']);
diff --git a/tests/Di/Aop/ProceedingJoinPointTest.php b/tests/Di/Aop/ProceedingJoinPointTest.php
index c8a690e22..0b2f89736 100644
--- a/tests/Di/Aop/ProceedingJoinPointTest.php
+++ b/tests/Di/Aop/ProceedingJoinPointTest.php
@@ -5,16 +5,15 @@
namespace Hypervel\Tests\Di\Aop;
use Hypervel\Di\Aop\ProceedingJoinPoint;
-use Hypervel\Tests\Di\Fixtures\ProxyTraitObject;
use Hypervel\Tests\TestCase;
class ProceedingJoinPointTest extends TestCase
{
- public function testProcessOriginalMethod()
+ public function testProcessOriginalMethod(): void
{
$obj = new ProceedingJoinPoint(
fn () => 1,
- ProxyTraitObject::class,
+ ProceedingJoinPointTarget::class,
'incr',
['keys' => []]
);
@@ -22,11 +21,11 @@ public function testProcessOriginalMethod()
$this->assertSame(1, $obj->processOriginalMethod());
}
- public function testGetArguments()
+ public function testGetArguments(): void
{
$obj = new ProceedingJoinPoint(
fn () => 1,
- ProxyTraitObject::class,
+ ProceedingJoinPointTarget::class,
'incr',
['keys' => []]
);
@@ -34,7 +33,7 @@ public function testGetArguments()
$obj = new ProceedingJoinPoint(
fn () => 1,
- ProxyTraitObject::class,
+ ProceedingJoinPointTarget::class,
'get4',
['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => []], 'variadic' => 'variadic']
);
@@ -42,7 +41,7 @@ public function testGetArguments()
$obj = new ProceedingJoinPoint(
fn () => 1,
- ProxyTraitObject::class,
+ ProceedingJoinPointTarget::class,
'get4',
['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => [2, 'foo' => 3]], 'variadic' => 'variadic']
);
@@ -50,20 +49,20 @@ public function testGetArguments()
$obj = new ProceedingJoinPoint(
fn () => 1,
- ProxyTraitObject::class,
+ ProceedingJoinPointTarget::class,
'get4',
['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => [2, 'foo' => 3]], 'variadic' => '']
);
$this->assertSame([1, [2, 'foo' => 3]], $obj->getArguments());
}
- public function testGetInstance()
+ public function testGetInstance(): void
{
- $object = new ProxyTraitObject('TestName');
+ $object = new ProceedingJoinPointTarget('TestName');
$joinPoint = new ProceedingJoinPoint(
$object->getName(...),
- ProxyTraitObject::class,
+ ProceedingJoinPointTarget::class,
'getName',
['keys' => []]
);
@@ -71,11 +70,11 @@ public function testGetInstance()
$this->assertSame($object, $joinPoint->getInstance());
}
- public function testGetInstanceReturnsNullForStaticClosure()
+ public function testGetInstanceReturnsNullForStaticClosure(): void
{
$joinPoint = new ProceedingJoinPoint(
static fn () => 'value',
- ProxyTraitObject::class,
+ ProceedingJoinPointTarget::class,
'staticMethod',
['keys' => []]
);
@@ -83,3 +82,15 @@ public function testGetInstanceReturnsNullForStaticClosure()
$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 e0abdf892..ef1a6e37a 100644
--- a/tests/Di/Aop/ProxyCallVisitorTest.php
+++ b/tests/Di/Aop/ProxyCallVisitorTest.php
@@ -4,60 +4,812 @@
namespace Hypervel\Tests\Di\Aop;
+use Attribute;
+use Closure;
+use Error;
+use Hypervel\Di\Aop\AbstractAspect;
use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Di\Aop\Ast;
+use Hypervel\Di\Aop\AstVisitorRegistry;
+use Hypervel\Di\Aop\ProceedingJoinPoint;
use Hypervel\Di\Aop\ProxyCallVisitor;
-use Hypervel\Di\Aop\VisitorMetadata;
+use Hypervel\Di\Aop\ProxyMarker;
+use Hypervel\Di\Exceptions\InvalidDefinitionException;
use Hypervel\Tests\TestCase;
-use PhpParser\Node;
-use PhpParser\Node\Stmt\ClassMethod;
+use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionMethod;
+use RuntimeException;
+use ValueError;
class ProxyCallVisitorTest extends TestCase
{
- public function testShouldRewrite()
+ public function testPreservesArgumentIntrospectionAcrossCallShapes(): void
{
- $code = <<<'CODETEMPLATE'
+ $method = <<<'PHP'
+ public function inspect(string $first = 'first', string $second = 'second', string ...$rest): array
+ {
+ return [func_num_args(), func_get_args(), $first, $second, $rest];
+ }
+PHP;
+ $nativeClass = $this->className('NativeArgumentShapes');
+ $proxyClass = $this->className('ProxyArgumentShapes');
+
+ $this->evaluate($this->classSource($nativeClass, $method));
+ $this->evaluate($this->generate(
+ $proxyClass,
+ '/original/ProxyArgumentShapes.php',
+ $this->classSource($proxyClass, $method)
+ ));
+
+ $native = new $nativeClass;
+ $proxy = new $proxyClass;
+ $calls = [
+ static fn (object $target): array => $target->inspect(),
+ static fn (object $target): array => $target->inspect('changed'),
+ static fn (object $target): array => $target->inspect(second: 'changed'),
+ static fn (object $target): array => $target->inspect('one', 'two', 'three', 'four'),
+ static fn (object $target): array => $target->inspect(named: 'value'),
+ static fn (object $target): array => $target->inspect('one', named: 'value'),
+ ];
+
+ foreach ($calls as $call) {
+ $this->assertSame($call($native), $call($proxy));
+ }
+ }
+
+ public function testPreservesMethodLocalStateDefaultsAndMagicConstants(): void
+ {
+ $className = $this->className();
+ $sourcePath = '/original/aop/StatefulTarget.php';
+ $source = $this->classSource($className, <<<'PHP'
+ /** Original documentation. */
+ #[\Hypervel\Tests\Di\Aop\ProxyMethodAttribute]
+ public function inspect(object $value = new \stdClass): array
+ {
+ static $calls = 0;
+ ++$calls;
+
+ $nested = function (string $value): array {
+ $inner = fn (): array => [__FUNCTION__, __METHOD__];
+
+ return [
+ __FUNCTION__,
+ __METHOD__,
+ __FILE__,
+ __DIR__,
+ __LINE__,
+ func_num_args(),
+ func_get_args(),
+ $inner(),
+ ];
+ };
+
+ return [
+ $value,
+ $calls,
+ __FUNCTION__,
+ __METHOD__,
+ __FILE__,
+ __DIR__,
+ __LINE__,
+ $nested('nested'),
+ ];
+ }
+PHP);
+
+ $outerLine = substr_count(substr($source, 0, strpos($source, '$nested = function')), "\n") + 1;
+ $innerLine = substr_count(substr($source, 0, strpos($source, '$inner = fn')), "\n") + 1;
+ $outerDescriptor = "{closure:{$className}::inspect():{$outerLine}}";
+ $innerDescriptor = "{closure:{$outerDescriptor}:{$innerLine}}";
+
+ $generated = $this->generate($className, $sourcePath, $source);
+ $this->evaluate($generated);
+
+ $first = (new $className)->inspect();
+ $second = (new $className)->inspect();
+
+ $this->assertNotSame($first[0], $second[0]);
+ $this->assertSame([1, 2], [$first[1], $second[1]]);
+ $this->assertSame('inspect', $first[2]);
+ $this->assertSame($className . '::inspect', $first[3]);
+ $this->assertSame($sourcePath, $first[4]);
+ $this->assertSame(dirname($sourcePath), $first[5]);
+ $this->assertIsInt($first[6]);
+ $this->assertSame([$outerDescriptor, $outerDescriptor], [$first[7][0], $first[7][1]]);
+ $this->assertSame([$sourcePath, dirname($sourcePath)], [$first[7][2], $first[7][3]]);
+ $this->assertSame([1, ['nested']], [$first[7][5], $first[7][6]]);
+ $this->assertSame([$innerDescriptor, $innerDescriptor], $first[7][7]);
+ $this->assertCount(1, (new ReflectionMethod($className, 'inspect'))->getAttributes(ProxyMethodAttribute::class));
+ $this->assertSame(1, substr_count($generated, '/** Original documentation. */'));
+ }
+
+ public function testPreservesReferenceMutationFromAspectsAndOriginalBodies(): void
+ {
+ $class = $this->proxyClass(
+ <<<'PHP'
+ public function mutate(int &$value, string &...$rest): void
+ {
+ ++$value;
+
+ foreach ($rest as &$item) {
+ $item .= '-body';
+ }
+ }
+
+PHP,
+ MutatingArgumentsAspect::class
+ );
+
+ $value = 1;
+ $first = 'first';
+ $second = 'second';
+
+ (new $class)->mutate($value, $first, $second);
+
+ $this->assertSame(12, $value);
+ $this->assertSame('first-aspect-body', $first);
+ $this->assertSame('second-body', $second);
+ }
+
+ public function testNativeClosureDescriptorFormatHasNotDrifted(): void
+ {
+ $fixture = new NativeClosureDescriptorFixture;
+
+ $this->assertMatchesRegularExpression(
+ '/^\{closure:' . preg_quote(NativeClosureDescriptorFixture::class, '/') . '::descriptor\(\):\d+\}$/D',
+ $fixture->descriptor()
+ );
+ $this->assertMatchesRegularExpression(
+ '/^\{closure:\{closure:' . preg_quote(NativeClosureDescriptorFixture::class, '/')
+ . '::nestedDescriptor\(\):\d+\}:\d+\}$/D',
+ $fixture->nestedDescriptor()
+ );
+ }
+
+ public function testNormalizesLeadingBackslashesInMagicDescriptors(): void
+ {
+ $className = $this->className('LeadingSlash');
+ $source = $this->classSource($className, <<<'PHP'
+ public function descriptor(): array
+ {
+ return [__METHOD__, (fn (): string => __FUNCTION__)()];
+ }
+PHP);
+
+ $this->evaluate($this->generate(
+ '\\' . $className,
+ '/original/LeadingSlash.php',
+ $source
+ ));
+
+ [$method, $closure] = (new $className)->descriptor();
+
+ $this->assertSame($className . '::descriptor', $method);
+ $this->assertStringStartsWith('{closure:' . $className . '::descriptor():', $closure);
+ }
+
+ public function testPreservesClosuresDeclaredInsideNestedNamedFunctions(): void
+ {
+ $className = $this->className('NestedFunction');
+ $namespace = substr($className, 0, strrpos($className, '\\'));
+ $source = $this->classSource($className, <<<'PHP'
+ public function inspect(): array
+ {
+ function localFunction(): array
+ {
+ $closure = fn (): array => [__FUNCTION__, __METHOD__];
+
+ return [__FUNCTION__, __METHOD__, $closure()];
+ }
+
+ return localFunction();
+ }
+PHP);
+ $closureLine = substr_count(substr($source, 0, strpos($source, '$closure = fn')), "\n") + 1;
+ $function = $namespace . '\localFunction';
+
+ $this->evaluate($this->generate($className, '/original/NestedFunction.php', $source));
+
+ $this->assertSame(
+ [$function, $function, ["{closure:{$function}():{$closureLine}}", "{closure:{$function}():{$closureLine}}"]],
+ (new $className)->inspect()
+ );
+ }
+
+ public function testPreservesPrivateStaticVoidAndNeverMethods(): void
+ {
+ $class = $this->proxyClass(
+ <<<'PHP'
+ public function callPrivate(int $value): int
+ {
+ return $this->privateValue($value);
+ }
+
+ private function privateValue(int $value): int
+ {
+ return $value * 2;
+ }
+
+ public static function staticValue(int $value): int
+ {
+ return $value + 1;
+ }
+
+ public function touch(int &$value): void
+ {
+ ++$value;
+ }
+
+ public function stop(): never
+ {
+ throw new \RuntimeException('stopped');
+ }
+PHP,
+ rule: '*'
+ );
+
+ $instance = new $class;
+ $value = 1;
+
+ $this->assertSame(6, $instance->callPrivate(3));
+ $this->assertSame(4, $class::staticValue(3));
+ $this->assertNull($instance->touch($value));
+ $this->assertSame(2, $value);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('stopped');
+ $instance->stop();
+ }
+
+ public function testUsesCollisionFreeGeneratedNames(): void
+ {
+ $className = $this->className();
+ $hash = substr(hash('sha256', $className . '::target'), 0, 12);
+ $source = $this->classSource($className, <<generate($className, '/original/Collision.php', $source, rule: 'target');
+ $this->evaluate($generated);
+
+ $this->assertSame(14, (new $className)->target(3));
+ $this->assertStringContainsString("__hypervelAopOriginal_{$hash}_1", $generated);
+ $this->assertStringContainsString("__hypervelAopCount_{$hash}_1", $generated);
+ }
+
+ public function testMarksProxiedTraitsWithoutInjectingMethods(): void
+ {
+ $traitName = $this->className('GeneratedTrait');
+ $traitSource = $this->traitSource($traitName, <<<'PHP'
+ public function value(): string
+ {
+ return 'proxied-trait';
+ }
+
+ public function descriptor(): string
+ {
+ return (fn (): string => __FUNCTION__)();
+ }
+PHP);
+
+ $closureLine = substr_count(substr($traitSource, 0, strpos($traitSource, 'fn (): string')), "\n") + 1;
+
+ $this->evaluate($this->generate($traitName, '/original/GeneratedTrait.php', $traitSource));
+
+ $className = $this->className('TraitConsumer');
+ $separator = strrpos($className, '\\');
+ $namespace = substr($className, 0, $separator);
+ $shortName = substr($className, $separator + 1);
+
+ eval("namespace {$namespace}; class {$shortName} { use \\{$traitName} { value as aliasedValue; } }");
+
+ $instance = new $className;
+
+ $this->assertSame('proxied-trait', $instance->value());
+ $this->assertSame('proxied-trait', $instance->aliasedValue());
+ $this->assertSame("{closure:{$traitName}::descriptor():{$closureLine}}", $instance->descriptor());
+ $this->assertContains(ProxyMarker::class, class_uses_recursive($instance));
+ $this->assertFalse(method_exists($instance, '__proxyCall'));
+ }
+
+ public function testProxiesEnumMethodsAndMarksTheGeneratedEnum(): void
+ {
+ $enumName = $this->className('GeneratedEnum');
+ $separator = strrpos($enumName, '\\');
+ $namespace = substr($enumName, 0, $separator);
+ $shortName = substr($enumName, $separator + 1);
+ $source = <<parse($code)[0];
+ $this->evaluate($this->generate($enumName, '/original/GeneratedEnum.php', $source));
- $aspect = 'App\Aspect\DebugAspect';
- AspectCollector::setAround($aspect, [
- 'SomeClass',
- ]);
+ $this->assertSame('proxied-enum', $enumName::Value->label());
+ $this->assertContains(ProxyMarker::class, class_uses_recursive($enumName));
+ }
- $proxyCallVisitor = new ProxyCallVisitor(new VisitorMetadata('SomeClass'));
+ public function testComposesMultipleProxiedTraitsWithoutMarkerCollisions(): void
+ {
+ $firstTrait = $this->className('FirstGeneratedTrait');
+ $secondTrait = $this->className('SecondGeneratedTrait');
+
+ $this->evaluate($this->generate(
+ $firstTrait,
+ '/original/FirstGeneratedTrait.php',
+ $this->traitSource($firstTrait, <<<'PHP'
+ public function first(): string
+ {
+ return 'first';
+ }
+PHP)
+ ));
+ $this->evaluate($this->generate(
+ $secondTrait,
+ '/original/SecondGeneratedTrait.php',
+ $this->traitSource($secondTrait, <<<'PHP'
+ public function second(): string
+ {
+ return 'second';
+ }
+PHP)
+ ));
+
+ $className = $this->className('MultipleTraitConsumer');
+ $separator = strrpos($className, '\\');
+ $namespace = substr($className, 0, $separator);
+ $shortName = substr($className, $separator + 1);
+
+ eval("namespace {$namespace}; class {$shortName} { use \\{$firstTrait}, \\{$secondTrait}; }");
+
+ $instance = new $className;
- $reflectionMethod = new ReflectionMethod($proxyCallVisitor, 'shouldRewrite');
- $this->assertFalse($reflectionMethod->invoke($proxyCallVisitor, $stmts->stmts[0]));
- $this->assertTrue($reflectionMethod->invoke($proxyCallVisitor, $stmts->stmts[1]));
+ $this->assertSame('first', $instance->first());
+ $this->assertSame('second', $instance->second());
+ $this->assertContains(ProxyMarker::class, class_uses_recursive($instance));
}
- public function testInterfaceShouldNotRewrite()
+ public function testPreservesNamespaceFunctionResolution(): void
{
- $aspect = 'App\Aspect\DebugAspect';
- AspectCollector::setAround($aspect, [
- 'SomeClass',
- ]);
+ $className = $this->className('NamespaceFunctions');
+ $separator = strrpos($className, '\\');
+ $namespace = substr($className, 0, $separator);
+ $shortName = substr($className, $separator + 1);
+ $source = <<assertTrue($reflectionMethod->invoke($proxyCallVisitor, new ClassMethod('foo')));
+function func_get_args(): array
+{
+ return ['shadow'];
+}
- $visitorMetadata->classLike = Node\Stmt\Interface_::class;
- $this->assertFalse($reflectionMethod->invoke($proxyCallVisitor, new ClassMethod('foo')));
+function func_get_arg(int \$position): string
+{
+ return "shadow-{\$position}";
+}
+
+class {$shortName}
+{
+ public function inspect(string \$value): array
+ {
+ return [func_num_args(), func_get_args(), func_get_arg(0)];
+ }
+}
+PHP;
+
+ $this->evaluate($this->generate($className, '/original/NamespaceFunctions.php', $source));
+
+ $this->assertSame([99, ['shadow'], 'shadow-0'], (new $className)->inspect('value'));
+ }
+
+ public function testRewritesImportedAndNamedArgumentBuiltins(): void
+ {
+ $className = $this->className('ImportedFunctions');
+ $separator = strrpos($className, '\\');
+ $namespace = substr($className, 0, $separator);
+ $shortName = substr($className, $separator + 1);
+ $source = <<evaluate($this->generate($className, '/original/ImportedFunctions.php', $source));
+
+ $this->assertSame([1, 1, 'value'], (new $className)->inspect('value'));
+ }
+
+ public function testLeavesDefinitelyCustomUnpackedFunctionsUntouched(): void
+ {
+ $className = $this->className('CustomUnpackedFunction');
+ $separator = strrpos($className, '\\');
+ $namespace = substr($className, 0, $separator);
+ $shortName = substr($className, $separator + 1);
+ $source = <<evaluate($this->generate($className, '/original/CustomUnpackedFunction.php', $source));
+
+ $this->assertSame(2, (new $className)->inspect());
+ }
+
+ public function testLeavesFirstClassArgumentCallableBehaviorUntouched(): void
+ {
+ $class = $this->proxyClass(<<<'PHP'
+ public function callback(): \Closure
+ {
+ return func_num_args(...);
+ }
+PHP);
+
+ $callback = (new $class)->callback();
+
+ $this->assertInstanceOf(Closure::class, $callback);
+ $this->expectException(Error::class);
+ $this->expectExceptionMessage('Cannot call func_num_args() dynamically');
+ $callback();
+ }
+
+ public function testLeavesAlreadyInvalidFullyQualifiedDynamicArgumentCallsNative(): void
+ {
+ $method = <<<'PHP'
+ public function numArgs(): mixed
+ {
+ return call_user_func('\func_num_args');
+ }
+
+ public function getArgs(): mixed
+ {
+ return call_user_func_array('\func_get_args', []);
+ }
+PHP;
+ $nativeClass = $this->className('NativeDynamicArguments');
+ $proxyClass = $this->className('ProxyDynamicArguments');
+
+ $this->evaluate($this->classSource($nativeClass, $method));
+ $this->evaluate($this->generate(
+ $proxyClass,
+ '/original/ProxyDynamicArguments.php',
+ $this->classSource($proxyClass, $method)
+ ));
+
+ foreach (['numArgs', 'getArgs'] as $methodName) {
+ $nativeError = null;
+ $proxyError = null;
+
+ try {
+ (new $nativeClass)->{$methodName}();
+ } catch (Error $error) {
+ $nativeError = $error;
+ }
+
+ try {
+ (new $proxyClass)->{$methodName}();
+ } catch (Error $error) {
+ $proxyError = $error;
+ }
+
+ $this->assertInstanceOf(Error::class, $nativeError);
+ $this->assertInstanceOf(Error::class, $proxyError);
+ $this->assertSame($nativeError->getMessage(), $proxyError->getMessage());
+ }
+ }
+
+ public function testPreservesNativeFuncGetArgErrors(): void
+ {
+ $method = <<<'PHP'
+ public function argument(int $position): mixed
+ {
+ return func_get_arg($position);
+ }
+PHP;
+ $nativeClass = $this->className('NativeArgumentErrors');
+ $proxyClass = $this->className('ProxyArgumentErrors');
+
+ $this->evaluate($this->classSource($nativeClass, $method));
+ $this->evaluate($this->generate(
+ $proxyClass,
+ '/original/ProxyArgumentErrors.php',
+ $this->classSource($proxyClass, $method)
+ ));
+
+ foreach ([-1, 1] as $position) {
+ $nativeError = null;
+ $proxyError = null;
+
+ try {
+ (new $nativeClass)->argument($position);
+ } catch (ValueError $error) {
+ $nativeError = $error;
+ }
+
+ try {
+ (new $proxyClass)->argument($position);
+ } catch (ValueError $error) {
+ $proxyError = $error;
+ }
+
+ $this->assertInstanceOf(ValueError::class, $nativeError);
+ $this->assertInstanceOf(ValueError::class, $proxyError);
+ $this->assertSame($nativeError->getMessage(), $proxyError->getMessage());
+ }
+ }
+
+ #[DataProvider('unsupportedSourceProvider')]
+ public function testRejectsSourceThatCannotBeProxiedSafely(string $method, string $message): void
+ {
+ $className = $this->className();
+ $source = $this->classSource($className, $method);
+
+ $this->expectException(InvalidDefinitionException::class);
+ $this->expectExceptionMessage($message);
+
+ $this->generate($className, '/original/Unsupported.php', $source);
+ }
+
+ public static function unsupportedSourceProvider(): array
+ {
+ return [
+ 'reference return' => [
+ <<<'PHP'
+ public function &target(): mixed
+ {
+ static $value;
+
+ return $value;
+ }
+PHP,
+ 'methods that return by reference cannot be intercepted safely',
+ ],
+ 'call_user_func' => [
+ <<<'PHP'
+ public function target(): int
+ {
+ return call_user_func('func_num_args');
+ }
+PHP,
+ 'indirect calls to func_num_args() cannot preserve the original method frame',
+ ],
+ 'call_user_func_array' => [
+ <<<'PHP'
+ public function target(string $value): string
+ {
+ return call_user_func_array('func_get_arg', [0]);
+ }
+PHP,
+ 'indirect calls to func_get_arg() cannot preserve the original method frame',
+ ],
+ 'unpacked num args' => [
+ <<<'PHP'
+ public function target(): int
+ {
+ return func_num_args(...[]);
+ }
+PHP,
+ 'calls to func_num_args() cannot use argument unpacking',
+ ],
+ 'unpacked get args' => [
+ <<<'PHP'
+ public function target(): array
+ {
+ return func_get_args(...[]);
+ }
+PHP,
+ 'calls to func_get_args() cannot use argument unpacking',
+ ],
+ 'unpacked get arg' => [
+ <<<'PHP'
+ public function target(string $value): string
+ {
+ return func_get_arg(...[0]);
+ }
+PHP,
+ 'calls to func_get_arg() cannot use argument unpacking',
+ ],
+ ];
+ }
+
+ public function testRejectsSourcesWithMultipleNamedClassLikes(): void
+ {
+ $className = $this->className();
+ $source = $this->classSource($className, <<<'PHP'
+ public function target(): string
+ {
+ return 'target';
+ }
+PHP) . "\nclass AnotherNamedClass {}\n";
+
+ $this->expectException(InvalidDefinitionException::class);
+ $this->expectExceptionMessage('contains multiple named classes, interfaces, traits, or enums');
+
+ $this->generate($className, '/original/Multiple.php', $source);
+ }
+
+ /**
+ * Generate, load, and return a unique proxied class.
+ */
+ private function proxyClass(string $method, string $aspect = 'GeneratedAspect', string $rule = '*'): string
+ {
+ $className = $this->className();
+ $source = $this->classSource($className, $method);
+ $generated = $this->generate($className, '/original/' . class_basename($className) . '.php', $source, $aspect, $rule);
+
+ $this->evaluate($generated);
+
+ return $className;
+ }
+
+ /**
+ * Generate proxy source for one class or trait.
+ */
+ private function generate(
+ string $className,
+ string $sourcePath,
+ string $source,
+ string $aspect = 'GeneratedAspect',
+ string $rule = '*'
+ ): string {
+ $target = $rule === '*' ? $className : $className . '::' . $rule;
+ AspectCollector::setAround($aspect, [$target]);
+
+ if (! AstVisitorRegistry::exists(ProxyCallVisitor::class)) {
+ AstVisitorRegistry::insert(ProxyCallVisitor::class);
+ }
+
+ $generated = (new Ast)->proxy($className, $sourcePath, $source);
+
+ if ($aspect === 'GeneratedAspect') {
+ AspectCollector::forgetAspect($aspect);
+ }
+
+ return $generated;
+ }
+
+ /**
+ * Evaluate generated PHP code in the current process.
+ */
+ private function evaluate(string $code): void
+ {
+ eval(substr($code, strlen('arguments['keys']['value'] += 10;
+ $proceedingJoinPoint->arguments['keys']['rest'][0] .= '-aspect';
+
+ return $proceedingJoinPoint->process();
+ }
+}
+
+#[Attribute]
+class ProxyMethodAttribute
+{
+}
+
+class NativeClosureDescriptorFixture
+{
+ public function descriptor(): string
+ {
+ return (fn (): string => __FUNCTION__)();
+ }
+
+ public function nestedDescriptor(): string
+ {
+ return (fn (): string => (fn (): string => __FUNCTION__)())();
}
}
diff --git a/tests/Di/Aop/ProxyDispatcherTest.php b/tests/Di/Aop/ProxyDispatcherTest.php
new file mode 100644
index 000000000..929c600dc
--- /dev/null
+++ b/tests/Di/Aop/ProxyDispatcherTest.php
@@ -0,0 +1,191 @@
+arguments(['value' => 'first', 'rest' => ['second', 'named' => 'third']]),
+ $target->combine(...)
+ );
+
+ $this->assertSame(['first', 'second', 'named' => 'third'], $result);
+ $this->assertSame([], AspectManager::get(ProxyDispatcherTarget::class, 'combine'));
+ }
+
+ public function testPublishesAndReusesTheCompletePrioritizedAspectList(): void
+ {
+ AspectCollector::setAround(DispatcherIncrementAspect::class, [
+ ProxyDispatcherTarget::class . '::number',
+ ProxyDispatcherTarget::class . '::number',
+ ], 20);
+ AspectCollector::setAround(DispatcherDoubleAspect::class, [
+ ProxyDispatcherTarget::class . '::number',
+ ], 10);
+
+ $target = new ProxyDispatcherTarget;
+ $arguments = $this->arguments(['value' => 2]);
+
+ $this->assertSame(5, ProxyDispatcher::dispatch(
+ ProxyDispatcherTarget::class,
+ 'number',
+ $arguments,
+ $target->number(...)
+ ));
+ $this->assertSame(
+ [DispatcherIncrementAspect::class, DispatcherDoubleAspect::class],
+ AspectManager::get(ProxyDispatcherTarget::class, 'number')
+ );
+
+ AspectCollector::flushState();
+
+ $this->assertSame(5, ProxyDispatcher::dispatch(
+ ProxyDispatcherTarget::class,
+ 'number',
+ $arguments,
+ $target->number(...)
+ ));
+ }
+
+ public function testExposesTheBoundInstanceToAspects(): void
+ {
+ AspectCollector::setAround(DispatcherInstanceAspect::class, [
+ ProxyDispatcherTarget::class . '::name',
+ ]);
+
+ $target = new ProxyDispatcherTarget('bound');
+
+ $this->assertSame('bound', ProxyDispatcher::dispatch(
+ ProxyDispatcherTarget::class,
+ 'name',
+ $this->arguments([]),
+ $target->name(...)
+ ));
+ }
+
+ public function testReconstructsVisibleArguments(): void
+ {
+ $this->assertSame(
+ ['changed', 'second', 'third'],
+ ProxyDispatcher::resolveArguments(3, ['changed', 'second'], ['third', 'named' => 'ignored'])
+ );
+ $this->assertSame(
+ ['changed'],
+ ProxyDispatcher::resolveArguments(1, ['changed', 'second'], ['third'])
+ );
+ $this->assertSame(
+ 'second',
+ ProxyDispatcher::resolveArgument(2, ['first', 'second'], [], 1)
+ );
+ }
+
+ public function testRejectsAnInvalidVisibleArgumentPosition(): void
+ {
+ $this->expectException(ValueError::class);
+ $this->expectExceptionMessage(
+ 'func_get_arg(): Argument #1 ($position) must be less than the number of the arguments passed '
+ . 'to the currently executed function'
+ );
+
+ ProxyDispatcher::resolveArgument(1, ['first'], [], 1);
+ }
+
+ public function testCapturesOnlyOriginalPositionalVariadicsByValue(): void
+ {
+ $arguments = ['first', 'second', 'named' => 'third'];
+ $captured = ProxyDispatcher::captureVariadicArguments($arguments, 1, false);
+
+ $arguments[0] = 'changed';
+
+ $this->assertSame(['first'], $captured);
+ }
+
+ public function testCapturesOriginalPositionalVariadicsByReference(): void
+ {
+ $first = 'first';
+ $second = 'second';
+ $arguments = [&$first, &$second, 'named' => 'third'];
+ $captured = ProxyDispatcher::captureVariadicArguments($arguments, 2, true);
+
+ $captured[0] = 'changed';
+ $captured[1] = 'updated';
+
+ $this->assertSame(['changed', 'updated'], [$first, $second]);
+ }
+
+ /**
+ * Build the argument structure consumed by ProceedingJoinPoint.
+ *
+ * @param array $values
+ */
+ private function arguments(array $values): array
+ {
+ return [
+ 'order' => array_keys($values),
+ 'keys' => $values,
+ 'variadic' => array_key_exists('rest', $values) ? 'rest' : '',
+ ];
+ }
+}
+
+class ProxyDispatcherTarget
+{
+ public function __construct(public string $value = '')
+ {
+ }
+
+ public function combine(string $value, string ...$rest): array
+ {
+ return [$value, ...$rest];
+ }
+
+ public function number(int $value): int
+ {
+ return $value;
+ }
+
+ public function name(): string
+ {
+ return $this->value;
+ }
+}
+
+class DispatcherIncrementAspect extends AbstractAspect
+{
+ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed
+ {
+ return $proceedingJoinPoint->process() + 1;
+ }
+}
+
+class DispatcherDoubleAspect extends AbstractAspect
+{
+ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed
+ {
+ return $proceedingJoinPoint->process() * 2;
+ }
+}
+
+class DispatcherInstanceAspect extends AbstractAspect
+{
+ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed
+ {
+ return $proceedingJoinPoint->getInstance()?->value;
+ }
+}
diff --git a/tests/Di/Aop/ProxyTraitTest.php b/tests/Di/Aop/ProxyTraitTest.php
deleted file mode 100644
index 293f842a7..000000000
--- a/tests/Di/Aop/ProxyTraitTest.php
+++ /dev/null
@@ -1,126 +0,0 @@
-assertEquals(['id' => null, 'str' => ''], $obj->get(null)['keys']);
- $this->assertEquals(['id', 'str'], $obj->get(null)['order']);
- $this->assertEquals('', $obj->get(null)['variadic']);
-
- $this->assertEquals(['id' => 1, 'str' => ''], $obj->get2()['keys']);
- $this->assertEquals(['id', 'str'], $obj->get2()['order']);
- $this->assertEquals('', $obj->get2()['variadic']);
-
- $this->assertEquals(['id' => null, 'str' => ''], $obj->get2(null)['keys']);
- $this->assertEquals(['id', 'str'], $obj->get2(null)['order']);
- $this->assertEquals('', $obj->get2(null)['variadic']);
-
- $this->assertEquals(['id' => 1, 'str' => '', 'num' => 1.0], $obj->get3()['keys']);
- $this->assertEquals(['id', 'str', 'num'], $obj->get3()['order']);
- $this->assertEquals('', $obj->get3()['variadic']);
-
- $this->assertEquals(['id' => 1, 'str' => 'hy', 'num' => 1.0], $obj->get3(1, 'hy')['keys']);
- $this->assertEquals(['id', 'str', 'num'], $obj->get3(1, 'hy')['order']);
-
- $this->assertEquals(['id' => 1, 'variadic' => []], $obj->get4(1)['keys']);
- $this->assertEquals(['id', 'variadic'], $obj->get4(1)['order']);
- $this->assertEquals('variadic', $obj->get4()['variadic']);
-
- $this->assertEquals(['id' => 1, 'variadic' => ['a' => 'a']], $obj->get4(a: 'a')['keys']);
- $this->assertEquals(['id' => null, 'variadic' => ['b', 'a' => 'a']], $obj->get4(null, 'b', a: 'a')['keys']);
- }
-
- public function testGetParamsMapOnTraitAlias(): void
- {
- $obj = new ProxyTraitObject;
-
- $this->assertEquals(['id' => null, 'str' => ''], $obj->getOnTrait(null)['keys']);
- $this->assertEquals(['id', 'str'], $obj->getOnTrait(null)['order']);
- $this->assertEquals('', $obj->getOnTrait(null)['variadic']);
-
- $this->assertEquals(['id' => 1, 'str' => ''], $obj->get2OnTrait()['keys']);
- $this->assertEquals(['id', 'str'], $obj->get2OnTrait()['order']);
-
- $this->assertEquals(['id' => null, 'str' => ''], $obj->get2OnTrait(null)['keys']);
- $this->assertEquals(['id', 'str'], $obj->get2OnTrait(null)['order']);
-
- $this->assertEquals(['id' => 1, 'str' => '', 'num' => 1.0], $obj->get3OnTrait()['keys']);
- $this->assertEquals(['id', 'str', 'num'], $obj->get3OnTrait()['order']);
-
- $this->assertEquals(['id' => 1, 'str' => 'hy', 'num' => 1.0], $obj->get3OnTrait(1, 'hy')['keys']);
- $this->assertEquals(['id', 'str', 'num'], $obj->get3OnTrait(1, 'hy')['order']);
-
- $this->assertEquals(['id' => 1, 'variadic' => []], $obj->get4OnTrait(1)['keys']);
- $this->assertEquals(['id', 'variadic'], $obj->get4OnTrait(1)['order']);
- $this->assertEquals('variadic', $obj->get4OnTrait(1)['variadic']);
-
- $this->assertEquals(['id' => 1, 'variadic' => ['a' => 'a']], $obj->get4OnTrait(a: 'a')['keys']);
- $this->assertEquals(['id' => null, 'variadic' => ['b', 'a' => 'a']], $obj->get4OnTrait(null, 'b', a: 'a')['keys']);
- }
-
- public function testProceedingJoinPointGetInstance(): void
- {
- $obj = new ProxyTraitObject;
- $this->assertSame('HypervelCloud', $obj->getName2());
-
- AspectCollector::setAround(GetNameAspect::class, [ProxyTraitObject::class]);
-
- $obj = new ProxyTraitObject;
- $this->assertSame('Hypervel', $obj->getName());
- }
-
- public function testProceedingJoinPointGetArguments(): void
- {
- AspectCollector::flushState();
-
- $obj = new ProxyTraitObject;
- $this->assertEquals(['id' => 1, 'variadic' => ['2', 'foo' => '3'], 'func_get_args' => [1, '2']], $obj->getParams(1, '2', foo: '3'));
-
- AspectCollector::setAround(GetParamsAspect::class, [ProxyTraitObject::class]);
-
- $obj = new ProxyTraitObject;
- $this->assertEquals([1, '2', 'foo' => '3'], $obj->getParams2(1, '2', foo: '3'));
- }
-
- public function testHandleAroundWithClassAspect(): void
- {
- AspectCollector::setAround(IncrAspect::class, [ProxyTraitObject::class]);
-
- $obj = new ProxyTraitObject;
- $this->assertSame(2, $obj->incr());
- }
-
- public function testMakePipelineReturnsFreshInstances(): void
- {
- $stub = new class {
- use ProxyTrait;
-
- public static function getPipeline(): Pipeline
- {
- return self::makePipeline();
- }
- };
-
- $first = $stub::getPipeline();
- $second = $stub::getPipeline();
-
- $this->assertInstanceOf(Pipeline::class, $first);
- $this->assertNotSame($first, $second);
- }
-}
diff --git a/tests/Di/Bootstrap/GenerateProxiesTest.php b/tests/Di/Bootstrap/GenerateProxiesTest.php
index 1792a709c..fd2cea8c2 100644
--- a/tests/Di/Bootstrap/GenerateProxiesTest.php
+++ b/tests/Di/Bootstrap/GenerateProxiesTest.php
@@ -9,191 +9,137 @@
use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Di\Aop\AstVisitorRegistry;
use Hypervel\Di\Aop\ProxyCallVisitor;
-use Hypervel\Di\Aop\ProxyManager;
+use Hypervel\Di\Aop\VisitorMetadata;
use Hypervel\Di\Bootstrap\GenerateProxies;
+use Hypervel\Di\Exceptions\InvalidDefinitionException;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Support\Composer;
+use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PhpParser\NodeVisitorAbstract;
use ReflectionMethod;
+use Throwable;
class GenerateProxiesTest extends TestCase
{
- private ?ClassLoader $originalLoader = null;
+ private ClassLoader $originalLoader;
- private ?ClassLoader $registeredLoader = null;
+ private ClassLoader $loader;
- protected function tearDown(): void
+ private Filesystem $filesystem;
+
+ private string $tempDirectory;
+
+ protected function setUp(): void
{
- if ($this->registeredLoader !== null) {
- $this->registeredLoader->unregister();
- $this->registeredLoader = null;
- }
+ parent::setUp();
- if ($this->originalLoader !== null) {
- Composer::setLoader($this->originalLoader);
- $this->originalLoader = null;
- }
+ $this->filesystem = new Filesystem;
+ $this->tempDirectory = ParallelTesting::tempDir('GenerateProxiesTest');
+ $this->filesystem->deleteDirectory($this->tempDirectory);
+ $this->filesystem->ensureDirectoryExists($this->tempDirectory);
+
+ $this->originalLoader = Composer::getLoader();
+ $this->loader = new ClassLoader;
+ $this->loader->register();
+ Composer::setLoader($this->loader);
+ }
+ protected function tearDown(): void
+ {
+ $this->loader->unregister();
+ Composer::setLoader($this->originalLoader);
GenerateProxies::flushState();
- ProxyManager::flushState();
+ $this->filesystem->deleteDirectory($this->tempDirectory);
parent::tearDown();
}
- public function testNoOpsWhenNoAspectsRegistered()
+ public function testNoOpsWhenNoAspectsRegistered(): void
{
- $app = m::mock(\Hypervel\Contracts\Foundation\Application::class);
- // storagePath should NOT be called
+ $app = m::mock(ApplicationContract::class);
$app->shouldNotReceive('storagePath');
- $bootstrapper = new GenerateProxies;
- $bootstrapper->bootstrap($app);
+ (new GenerateProxies)->bootstrap($app);
$this->assertFalse(AstVisitorRegistry::exists(ProxyCallVisitor::class));
}
- public function testRegistersProxyCallVisitorWhenAspectsExist()
+ public function testRegistersProxyCallVisitorWhenAspectsExist(): void
{
AspectCollector::setAround('SomeAspect', ['SomeNonExistentClass']);
- $app = m::mock(\Hypervel\Contracts\Foundation\Application::class);
- $app->shouldReceive('storagePath')
- ->with('framework/aop/')
- ->andReturn(sys_get_temp_dir() . '/hypervel-test-aop-' . uniqid() . '/');
-
- $bootstrapper = new GenerateProxies;
- $bootstrapper->bootstrap($app);
+ $this->bootstrapProxies($this->tempDirectory . '/aop');
$this->assertTrue(AstVisitorRegistry::exists(ProxyCallVisitor::class));
}
- public function testBuildClassMapResolvesPsr4ClassesViaFindFile()
+ public function testBuildClassMapResolvesPsr4ClassesViaFindFile(): void
{
- // Use a controlled ClassLoader with an empty class map but a PSR-4
- // prefix that can resolve a known class. This avoids dependency on
- // whether the real autoloader is optimized or not.
- $testClass = 'Hypervel\Support\Composer';
-
- $loader = new ClassLoader;
- // No class map entries — simulates a non-optimized autoloader
- $loader->addPsr4('Hypervel\Support\\', [__DIR__ . '/../../../src/support/src/']);
- $this->registerLoader($loader);
-
- $this->originalLoader = Composer::setLoader($loader);
+ $testClass = Composer::class;
+ $this->loader->addPsr4('Hypervel\Support\\', [__DIR__ . '/../../../src/support/src/']);
- $this->assertArrayNotHasKey($testClass, $loader->getClassMap());
- $this->assertNotFalse($loader->findFile($testClass));
+ $this->assertArrayNotHasKey($testClass, $this->loader->getClassMap());
+ $this->assertNotFalse($this->loader->findFile($testClass));
AspectCollector::setAround('TestAspect', [$testClass . '::getLoader']);
- $bootstrapper = new GenerateProxies;
- $reflection = new ReflectionMethod($bootstrapper, 'buildClassMap');
-
- $classMap = $reflection->invoke($bootstrapper);
+ $classMap = $this->buildClassMap();
$this->assertArrayHasKey($testClass, $classMap);
$this->assertStringContainsString('Composer.php', $classMap[$testClass]);
}
- public function testBuildClassMapSkipsWildcardRules()
+ public function testBuildClassMapSkipsWildcardRules(): void
{
+ $this->loader->addClassMap(['Existing\ClassName' => '/tmp/existing.php']);
AspectCollector::setAround('TestAspect', ['App\Services\*']);
- $bootstrapper = new GenerateProxies;
- $reflection = new ReflectionMethod($bootstrapper, 'buildClassMap');
-
- $classMap = $reflection->invoke($bootstrapper);
-
- // Wildcard rules should not add any new entries beyond what's already in the class map
- $this->assertSame(
- Composer::getLoader()->getClassMap(),
- $classMap
- );
+ $this->assertSame($this->loader->getClassMap(), $this->buildClassMap());
}
- public function testBuildClassMapDoesNotDuplicateExistingEntries()
+ public function testBuildClassMapDoesNotDuplicateExistingEntries(): void
{
- $loader = Composer::getLoader();
- $existingMap = $loader->getClassMap();
-
- if (empty($existingMap)) {
- $this->markTestSkipped('No classes in composer class map');
- }
+ $this->loader->addClassMap(['Existing\ClassName' => '/tmp/existing.php']);
+ AspectCollector::setAround('TestAspect', ['Existing\ClassName::method']);
- // Pick a class that's already in the class map
- $existingClass = array_key_first($existingMap);
- $existingPath = $existingMap[$existingClass];
-
- AspectCollector::setAround('TestAspect', [$existingClass . '::method']);
-
- $bootstrapper = new GenerateProxies;
- $reflection = new ReflectionMethod($bootstrapper, 'buildClassMap');
-
- $classMap = $reflection->invoke($bootstrapper);
-
- // Should use the existing entry, not override it
- $this->assertSame($existingPath, $classMap[$existingClass]);
+ $this->assertSame('/tmp/existing.php', $this->buildClassMap()['Existing\ClassName']);
}
- public function testBuildClassMapExtractsClassNameFromMethodRule()
+ public function testBuildClassMapExtractsClassNameFromMethodRule(): void
{
- $testClass = Composer::class;
-
- AspectCollector::setAround('TestAspect', [$testClass . '::getLoader']);
+ $this->loader->addClassMap([Composer::class => __DIR__ . '/../../../src/support/src/Composer.php']);
+ AspectCollector::setAround('TestAspect', [Composer::class . '::getLoader']);
- $bootstrapper = new GenerateProxies;
- $reflection = new ReflectionMethod($bootstrapper, 'buildClassMap');
-
- $classMap = $reflection->invoke($bootstrapper);
-
- $this->assertArrayHasKey($testClass, $classMap);
+ $this->assertArrayHasKey(Composer::class, $this->buildClassMap());
}
- public function testBuildClassMapHandlesClassRuleWithoutMethod()
+ public function testBuildClassMapHandlesClassRuleWithoutMethod(): void
{
- $testClass = Composer::class;
+ $this->loader->addClassMap([Composer::class => __DIR__ . '/../../../src/support/src/Composer.php']);
+ AspectCollector::setAround('TestAspect', [Composer::class]);
- AspectCollector::setAround('TestAspect', [$testClass]);
-
- $bootstrapper = new GenerateProxies;
- $reflection = new ReflectionMethod($bootstrapper, 'buildClassMap');
-
- $classMap = $reflection->invoke($bootstrapper);
-
- $this->assertArrayHasKey($testClass, $classMap);
+ $this->assertArrayHasKey(Composer::class, $this->buildClassMap());
}
- public function testBuildClassMapSkipsUnresolvableClasses()
+ public function testBuildClassMapSkipsUnresolvableClasses(): void
{
AspectCollector::setAround('TestAspect', ['Totally\NonExistent\Class123::method']);
- $bootstrapper = new GenerateProxies;
- $reflection = new ReflectionMethod($bootstrapper, 'buildClassMap');
-
- $classMap = $reflection->invoke($bootstrapper);
-
- $this->assertArrayNotHasKey('Totally\NonExistent\Class123', $classMap);
+ $this->assertArrayNotHasKey('Totally\NonExistent\Class123', $this->buildClassMap());
}
- public function testDoesNotRegisterProxyCallVisitorTwice()
+ public function testDoesNotRegisterProxyCallVisitorTwice(): void
{
AspectCollector::setAround('SomeAspect', ['SomeNonExistentClass']);
-
- // Pre-register the visitor
AstVisitorRegistry::insert(ProxyCallVisitor::class);
- $app = m::mock(\Hypervel\Contracts\Foundation\Application::class);
- $app->shouldReceive('storagePath')
- ->with('framework/aop/')
- ->andReturn(sys_get_temp_dir() . '/hypervel-test-aop-' . uniqid() . '/');
-
- $bootstrapper = new GenerateProxies;
- $bootstrapper->bootstrap($app);
+ $this->bootstrapProxies($this->tempDirectory . '/aop');
- // Count how many times the visitor appears
$queue = clone AstVisitorRegistry::getQueue();
$count = 0;
+
foreach ($queue as $item) {
if ($item === ProxyCallVisitor::class) {
++$count;
@@ -205,116 +151,222 @@ public function testDoesNotRegisterProxyCallVisitorTwice()
public function testRegeneratesDeletedProxyFilesFromTheCapturedSourceMap(): void
{
- $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir, ClassLoader $loader): void {
+ $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void {
AspectCollector::setAround('TestAspect', [$className . '::value']);
$this->bootstrapProxies($proxyDir);
- $proxyFile = $loader->getClassMap()[$className];
+ $proxyFile = $this->loader->getClassMap()[$className];
$this->assertFileExists($proxyFile);
- (new Filesystem)->deleteDirectory($proxyDir);
-
+ $this->filesystem->deleteDirectory($proxyDir);
$this->bootstrapProxies($proxyDir);
$this->assertFileExists($proxyFile);
- $this->assertStringContainsString('original-source', (string) file_get_contents($proxyFile));
+ $this->assertStringContainsString('original-source', $this->filesystem->get($proxyFile));
});
}
public function testSkipsProxyPathsReturnedByFindFileAfterTheSourceMapIsFlushed(): void
{
- $this->withPsr4ProxyFixture(function (string $className, string $proxyDir, ClassLoader $loader): void {
+ $this->withPsr4ProxyFixture(function (string $className, string $proxyDir): void {
AspectCollector::setAround('TestAspect', [$className . '::value']);
$this->bootstrapProxies($proxyDir);
- $proxyFile = $loader->getClassMap()[$className];
+ $proxyFile = $this->loader->getClassMap()[$className];
$this->assertFileExists($proxyFile);
GenerateProxies::flushState();
- ProxyManager::flushState();
- $bootstrapper = new GenerateProxies;
- $reflection = new ReflectionMethod($bootstrapper, 'buildClassMap');
+ $this->assertArrayNotHasKey($className, $this->buildClassMap());
- $classMap = $reflection->invoke($bootstrapper);
+ $this->filesystem->deleteDirectory($proxyDir);
+ $this->bootstrapProxies($proxyDir);
- $this->assertArrayNotHasKey($className, $classMap);
+ $this->assertFileDoesNotExist($proxyFile);
+ });
+ }
- (new Filesystem)->deleteDirectory($proxyDir);
+ public function testRegeneratesProxyWhenSourceChangesWithoutAnMtimeChange(): void
+ {
+ $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void {
+ AspectCollector::setAround('TestAspect', [$className . '::value']);
$this->bootstrapProxies($proxyDir);
+ $sourceMtime = filemtime($sourceFile);
- $this->assertFileDoesNotExist($proxyFile);
+ $this->writeProxySource($sourceFile, $className, 'same-mtime-source');
+ touch($sourceFile, $sourceMtime);
+ $this->bootstrapProxies($proxyDir);
+
+ $proxyFile = $this->loader->getClassMap()[$className];
+ $this->assertStringContainsString('same-mtime-source', $this->filesystem->get($proxyFile));
});
}
- public function testRegeneratesProxyFilesWhenTheSourceFileIsNewer(): void
+ public function testRegeneratesProxyWhenSourcePathChanges(): void
{
- $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir, ClassLoader $loader): void {
+ $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void {
AspectCollector::setAround('TestAspect', [$className . '::value']);
$this->bootstrapProxies($proxyDir);
+ $this->writeProxySource($overrideFile, $className, 'override-source');
+ touch($overrideFile, (int) filemtime($sourceFile) - 100);
+ $this->loader->addClassMap([$className => $overrideFile]);
+
+ $this->bootstrapProxies($proxyDir);
+
+ $proxyFile = $this->loader->getClassMap()[$className];
+ $this->assertStringContainsString('override-source', $this->filesystem->get($proxyFile));
+ });
+ }
+
+ public function testRegeneratesProxyWhenAspectRulesChange(): void
+ {
+ $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void {
+ AspectCollector::setAround('FirstAspect', [$className . '::value']);
- $proxyFile = $loader->getClassMap()[$className];
- $this->writeProxySource($sourceFile, $className, 'newer-source');
- touch($sourceFile, filemtime($proxyFile) + 2);
+ $this->bootstrapProxies($proxyDir);
+ $proxyFile = $this->loader->getClassMap()[$className];
+ $first = $this->filesystem->get($proxyFile);
+ AspectCollector::setAround('SecondAspect', [$className . '::value']);
$this->bootstrapProxies($proxyDir);
- $this->assertStringContainsString('newer-source', (string) file_get_contents($proxyFile));
+ $this->assertNotSame($first, $this->filesystem->get($proxyFile));
});
}
- public function testRegeneratesProxyFilesWhenTheSourcePathChanges(): void
+ public function testRegeneratesProxyWhenVisitorOrderChanges(): void
{
- $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir, ClassLoader $loader): void {
+ $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void {
AspectCollector::setAround('TestAspect', [$className . '::value']);
+ AstVisitorRegistry::insert(FingerprintVisitorOne::class, 20);
+ AstVisitorRegistry::insert(FingerprintVisitorTwo::class, 10);
$this->bootstrapProxies($proxyDir);
+ $proxyFile = $this->loader->getClassMap()[$className];
+ $first = $this->filesystem->get($proxyFile);
- $proxyFile = $loader->getClassMap()[$className];
- $this->writeProxySource($overrideFile, $className, 'override-source');
- touch($overrideFile, filemtime($proxyFile) - 100);
+ AstVisitorRegistry::flushState();
+ AstVisitorRegistry::insert(FingerprintVisitorOne::class, 10);
+ AstVisitorRegistry::insert(FingerprintVisitorTwo::class, 20);
+ $this->bootstrapProxies($proxyDir);
+
+ $this->assertNotSame($first, $this->filesystem->get($proxyFile));
+ });
+ }
+
+ public function testUsesCollisionFreeEncodedProxyFilenames(): void
+ {
+ $proxyDir = $this->tempDirectory . '/aop';
+ $firstClass = 'Hypervel\Tests\Di\Bootstrap\Fixtures\Encoded\One_Two';
+ $secondClass = 'Hypervel\Tests\Di\Bootstrap\Fixtures\Encoded_One\Two';
+ $firstSource = $this->tempDirectory . '/One_Two.php';
+ $secondSource = $this->tempDirectory . '/Two.php';
+
+ $this->writeProxySource($firstSource, $firstClass, 'first');
+ $this->writeProxySource($secondSource, $secondClass, 'second');
+ $this->loader->addClassMap([
+ $firstClass => $firstSource,
+ $secondClass => $secondSource,
+ ]);
+ AspectCollector::setAround('TestAspect', [$firstClass, $secondClass]);
+
+ $this->bootstrapProxies($proxyDir);
+
+ $firstProxy = $this->loader->getClassMap()[$firstClass];
+ $secondProxy = $this->loader->getClassMap()[$secondClass];
+ $this->assertNotSame($firstProxy, $secondProxy);
+ $this->assertSame(rawurlencode($firstClass) . '.proxy.php', basename($firstProxy));
+ $this->assertSame(rawurlencode($secondClass) . '.proxy.php', basename($secondProxy));
+ $this->assertFileExists($firstProxy);
+ $this->assertFileExists($secondProxy);
+ }
+
+ public function testReusesAProxyFromItsFingerprintHeaderWithoutParsingItsBody(): void
+ {
+ $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void {
+ AspectCollector::setAround('TestAspect', [$className . '::value']);
+ $this->bootstrapProxies($proxyDir);
- $loader->addClassMap([$className => $overrideFile]);
+ $proxyFile = $this->loader->getClassMap()[$className];
+ $lines = file($proxyFile);
+ $this->assertIsArray($lines);
+ $cachedBody = $lines[0] . $lines[1] . "cached-body-is-not-parsed\n";
+ $this->filesystem->put($proxyFile, $cachedBody);
$this->bootstrapProxies($proxyDir);
- $this->assertStringContainsString('override-source', (string) file_get_contents($proxyFile));
+ $this->assertSame($cachedBody, $this->filesystem->get($proxyFile));
});
}
+ public function testGenerationFailureDoesNotPublishAnyProxyClassMapEntries(): void
+ {
+ $validClass = 'Hypervel\Tests\Di\Bootstrap\Fixtures\ValidProxySource';
+ $invalidClass = 'Hypervel\Tests\Di\Bootstrap\Fixtures\InvalidProxySource';
+ $validSource = $this->tempDirectory . '/ValidProxySource.php';
+ $invalidSource = $this->tempDirectory . '/InvalidProxySource.php';
+ $proxyDir = $this->tempDirectory . '/aop';
+ $this->writeProxySource($validSource, $validClass, 'valid-source');
+ $this->filesystem->put($invalidSource, <<<'PHP'
+loader->addClassMap([
+ $validClass => $validSource,
+ $invalidClass => $invalidSource,
+ ]);
+ AspectCollector::setAround('TestAspect', [$validClass, $invalidClass]);
+
+ $exception = null;
+
+ try {
+ $this->bootstrapProxies($proxyDir);
+ } catch (Throwable $throwable) {
+ $exception = $throwable;
+ }
+
+ $this->assertInstanceOf(InvalidDefinitionException::class, $exception);
+ $this->assertSame($validSource, $this->loader->getClassMap()[$validClass]);
+ $this->assertSame($invalidSource, $this->loader->getClassMap()[$invalidClass]);
+ $this->assertFileDoesNotExist(
+ $proxyDir . DIRECTORY_SEPARATOR . rawurlencode($invalidClass) . '.proxy.php'
+ );
+ }
+
/**
- * Run a callback with a controlled proxy fixture.
+ * Build the source class map through the bootstrapper's protected boundary.
+ *
+ * @return array
+ */
+ private function buildClassMap(): array
+ {
+ return (new ReflectionMethod(GenerateProxies::class, 'buildClassMap'))->invoke(new GenerateProxies);
+ }
+
+ /**
+ * Run a callback with a controlled class-map proxy fixture.
*/
private function withProxyFixture(callable $callback): void
{
- $filesystem = new Filesystem;
- $directory = sys_get_temp_dir() . '/hypervel-test-aop-' . getmypid() . '-' . bin2hex(random_bytes(6));
- $sourceDirectory = $directory . '/src';
- $proxyDir = $directory . '/aop/';
+ $sourceDirectory = $this->tempDirectory . '/src';
+ $proxyDir = $this->tempDirectory . '/aop';
$className = 'Hypervel\Tests\Di\Bootstrap\Fixtures\AopProxySource' . bin2hex(random_bytes(4));
$sourceFile = $sourceDirectory . '/AopProxySource.php';
$overrideFile = $sourceDirectory . '/AopProxySourceOverride.php';
- $filesystem->ensureDirectoryExists($sourceDirectory);
+ $this->filesystem->ensureDirectoryExists($sourceDirectory);
$this->writeProxySource($sourceFile, $className, 'original-source');
+ $this->loader->addClassMap([$className => $sourceFile]);
- $loader = new ClassLoader;
- $loader->addClassMap([$className => $sourceFile]);
- $this->registerLoader($loader);
-
- $this->originalLoader = Composer::setLoader($loader);
- GenerateProxies::flushState();
- ProxyManager::flushState();
-
- try {
- $callback($className, $sourceFile, $overrideFile, $proxyDir, $loader);
- } finally {
- $filesystem->deleteDirectory($directory);
- }
+ $callback($className, $sourceFile, $overrideFile, $proxyDir);
}
/**
@@ -322,30 +374,17 @@ private function withProxyFixture(callable $callback): void
*/
private function withPsr4ProxyFixture(callable $callback): void
{
- $filesystem = new Filesystem;
- $directory = sys_get_temp_dir() . '/hypervel-test-aop-' . getmypid() . '-' . bin2hex(random_bytes(6));
- $sourceDirectory = $directory . '/src/';
- $proxyDir = $directory . '/aop/';
+ $sourceDirectory = $this->tempDirectory . '/src/';
+ $proxyDir = $this->tempDirectory . '/aop';
$shortName = 'AopProxyPsr4Source' . bin2hex(random_bytes(4));
$className = 'Hypervel\Tests\Di\Bootstrap\Fixtures\\' . $shortName;
$sourceFile = $sourceDirectory . $shortName . '.php';
- $filesystem->ensureDirectoryExists($sourceDirectory);
+ $this->filesystem->ensureDirectoryExists($sourceDirectory);
$this->writeProxySource($sourceFile, $className, 'psr4-source');
+ $this->loader->addPsr4('Hypervel\Tests\Di\Bootstrap\Fixtures\\', [$sourceDirectory]);
- $loader = new ClassLoader;
- $loader->addPsr4('Hypervel\Tests\Di\Bootstrap\Fixtures\\', [$sourceDirectory]);
- $this->registerLoader($loader);
-
- $this->originalLoader = Composer::setLoader($loader);
- GenerateProxies::flushState();
- ProxyManager::flushState();
-
- try {
- $callback($className, $proxyDir, $loader);
- } finally {
- $filesystem->deleteDirectory($directory);
- }
+ $callback($className, $proxyDir);
}
/**
@@ -361,15 +400,6 @@ private function bootstrapProxies(string $proxyDir): void
(new GenerateProxies)->bootstrap($app);
}
- /**
- * Register a controlled Composer loader.
- */
- private function registerLoader(ClassLoader $loader): void
- {
- $loader->register();
- $this->registeredLoader = $loader;
- }
-
/**
* Write a source file used for proxy generation.
*/
@@ -379,7 +409,7 @@ private function writeProxySource(string $file, string $className, string $marke
$namespace = substr($className, 0, $lastSeparator);
$shortName = substr($className, $lastSeparator + 1);
- file_put_contents($file, <<filesystem->put($file, <<originalLoader = Composer::getLoader();
+ $this->isolatedLoader = new ClassLoader;
+ $this->isolatedLoader->register();
+ Composer::setLoader($this->isolatedLoader);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->isolatedLoader->unregister();
+ Composer::setLoader($this->originalLoader);
+
+ parent::tearDown();
+ }
+
+ public function testHasEntriesReturnsFalseWhenEmpty(): void
{
$this->assertFalse(ClassMapManager::hasEntries());
}
- public function testAddRegistersEntriesAndAppliestoAutoloader()
+ public function testAddRegistersEntriesAndAppliesToAutoloader(): void
{
$fakePath = '/tmp/fake_replacement.php';
@@ -37,7 +60,7 @@ public function testAddRegistersEntriesAndAppliestoAutoloader()
$this->assertSame($fakePath, $composerMap['Hypervel\Tests\Di\ClassMap\NonExistentClassForTesting']);
}
- public function testAddThrowsWhenClassAlreadyLoaded()
+ public function testAddThrowsWhenClassAlreadyLoaded(): void
{
// This test class itself is already loaded
$this->expectException(RuntimeException::class);
@@ -48,7 +71,7 @@ public function testAddThrowsWhenClassAlreadyLoaded()
]);
}
- public function testAddThrowsWhenInterfaceAlreadyLoaded()
+ public function testAddThrowsWhenInterfaceAlreadyLoaded(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Cannot override class map');
@@ -58,20 +81,17 @@ public function testAddThrowsWhenInterfaceAlreadyLoaded()
]);
}
- public function testAddThrowsWhenTraitAlreadyLoaded()
+ public function testAddThrowsWhenTraitAlreadyLoaded(): void
{
- // Force-load the trait by referencing a class that uses it
- new \Hypervel\Tests\Di\Fixtures\ProxyTraitObject;
-
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Cannot override class map');
ClassMapManager::add([
- \Hypervel\Di\Aop\ProxyTrait::class => '/tmp/replacement.php',
+ LoadedTraitForClassMapTest::class => '/tmp/replacement.php',
]);
}
- public function testAddMergesMultipleCalls()
+ public function testAddMergesMultipleCalls(): void
{
ClassMapManager::add([
'Fake\ClassA' => '/tmp/a.php',
@@ -86,7 +106,7 @@ public function testAddMergesMultipleCalls()
], ClassMapManager::getEntries());
}
- public function testFlushStateRemovesAllEntries()
+ public function testFlushStateRemovesAllEntries(): void
{
ClassMapManager::add([
'Fake\ClassA' => '/tmp/a.php',
@@ -98,3 +118,7 @@ public function testFlushStateRemovesAllEntries()
$this->assertSame([], ClassMapManager::getEntries());
}
}
+
+trait LoadedTraitForClassMapTest
+{
+}
diff --git a/tests/Di/Fixtures/ProxyTraitObject.php b/tests/Di/Fixtures/ProxyTraitObject.php
deleted file mode 100644
index a15312414..000000000
--- a/tests/Di/Fixtures/ProxyTraitObject.php
+++ /dev/null
@@ -1,80 +0,0 @@
- ['id', 'str'], 'keys' => compact('id', 'str'), 'variadic' => ''];
- }
-
- public function get2(?int $id = 1, string $str = '')
- {
- return ['order' => ['id', 'str'], 'keys' => compact('id', 'str'), 'variadic' => ''];
- }
-
- public function get3(?int $id = 1, string $str = '', float $num = 1.0)
- {
- return ['order' => ['id', 'str', 'num'], 'keys' => compact('id', 'str', 'num'), 'variadic' => ''];
- }
-
- public function get4(?int $id = 1, string ...$variadic)
- {
- return ['order' => ['id', 'variadic'], 'keys' => compact('id', 'variadic'), 'variadic' => 'variadic'];
- }
-
- public function incr()
- {
- return self::__proxyCall(ProxyTraitObject::class, __FUNCTION__, ['keys' => []], function () {
- return 1;
- });
- }
-
- public function getName()
- {
- return self::__proxyCall(ProxyTraitObject::class, __FUNCTION__, ['keys' => []], function () {
- return 'HypervelCloud';
- });
- }
-
- public function getName2()
- {
- return self::__proxyCall(ProxyTraitObject::class, __FUNCTION__, ['keys' => []], function () {
- return 'HypervelCloud';
- });
- }
-
- public function getParams(?int $id = 1, string ...$variadic)
- {
- return self::__proxyCall(ProxyTraitObject::class, __FUNCTION__, ['order' => ['id', 'variadic'], 'keys' => compact(['id', 'variadic']), 'variadic' => 'variadic'], function (?int $id = 1, string ...$variadic) {
- return compact('id', 'variadic') + ['func_get_args' => func_get_args()];
- });
- }
-
- public function getParams2(?int $id = 1, string ...$variadic)
- {
- return self::__proxyCall(ProxyTraitObject::class, __FUNCTION__, ['order' => ['id', 'variadic'], 'keys' => compact(['id', 'variadic']), 'variadic' => 'variadic'], function (?int $id = 1, string ...$variadic) {
- return compact('id', 'variadic') + ['func_get_args' => func_get_args()];
- });
- }
-}
diff --git a/tests/Di/Fixtures/ProxyTraitOnTrait.php b/tests/Di/Fixtures/ProxyTraitOnTrait.php
deleted file mode 100644
index 28c8678ef..000000000
--- a/tests/Di/Fixtures/ProxyTraitOnTrait.php
+++ /dev/null
@@ -1,57 +0,0 @@
- ['id', 'str'], 'keys' => compact('id', 'str'), 'variadic' => ''];
- }
-
- public function get2(?int $id = 1, string $str = '')
- {
- return ['order' => ['id', 'str'], 'keys' => compact('id', 'str'), 'variadic' => ''];
- }
-
- public function get3(?int $id = 1, string $str = '', float $num = 1.0)
- {
- return ['order' => ['id', 'str', 'num'], 'keys' => compact('id', 'str', 'num'), 'variadic' => ''];
- }
-
- public function get4(?int $id = 1, string ...$variadic)
- {
- return ['order' => ['id', 'variadic'], 'keys' => compact('id', 'variadic'), 'variadic' => 'variadic'];
- }
-
- public function incr()
- {
- return self::__proxyCall(__TRAIT__, __FUNCTION__, ['keys' => []], function () {
- return 1;
- });
- }
-
- public function getName()
- {
- return self::__proxyCall(__TRAIT__, __FUNCTION__, ['keys' => []], function () {
- return 'HypervelCloud';
- });
- }
-
- public function getName2()
- {
- return self::__proxyCall(__TRAIT__, __FUNCTION__, ['keys' => []], function () {
- return 'HypervelCloud';
- });
- }
-}
diff --git a/tests/Di/ServiceProviderDiTest.php b/tests/Di/ServiceProviderDiTest.php
index 23196b640..2db0520ec 100644
--- a/tests/Di/ServiceProviderDiTest.php
+++ b/tests/Di/ServiceProviderDiTest.php
@@ -4,9 +4,11 @@
namespace Hypervel\Tests\Di;
+use Composer\Autoload\ClassLoader;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Di\ClassMap\ClassMapManager;
+use Hypervel\Support\Composer;
use Hypervel\Support\ServiceProvider;
use Hypervel\Tests\Di\Fixtures\Aspect\NoPriorityAspect;
use Hypervel\Tests\Di\Fixtures\Aspect\TestAspect;
@@ -15,7 +17,7 @@
class ServiceProviderDiTest extends TestCase
{
- public function testAspectsRegistersIntoCollector()
+ public function testAspectsRegistersIntoCollector(): void
{
$provider = new TestServiceProvider($this->createMockApp());
$provider->register();
@@ -27,7 +29,7 @@ public function testAspectsRegistersIntoCollector()
$this->assertSame(10, $rule['priority']);
}
- public function testAspectsWithNoPriorityDefaultsToZero()
+ public function testAspectsWithNoPriorityDefaultsToZero(): void
{
$provider = new NoPriorityServiceProvider($this->createMockApp());
$provider->register();
@@ -35,7 +37,7 @@ public function testAspectsWithNoPriorityDefaultsToZero()
$this->assertSame(0, AspectCollector::getPriority(NoPriorityAspect::class));
}
- public function testAspectsAcceptsMultipleArguments()
+ public function testAspectsAcceptsMultipleArguments(): void
{
$provider = new MultiAspectServiceProvider($this->createMockApp());
$provider->register();
@@ -44,16 +46,26 @@ public function testAspectsAcceptsMultipleArguments()
$this->assertNotEmpty(AspectCollector::getRule(NoPriorityAspect::class));
}
- public function testClassMapDelegatesToClassMapManager()
+ public function testClassMapDelegatesToClassMapManager(): void
{
- $provider = new ClassMapServiceProvider($this->createMockApp());
- $provider->register();
-
- $this->assertTrue(ClassMapManager::hasEntries());
- $this->assertSame(
- ['Fake\OriginalClass' => '/tmp/replacement.php'],
- ClassMapManager::getEntries()
- );
+ $originalLoader = Composer::getLoader();
+ $isolatedLoader = new ClassLoader;
+ $isolatedLoader->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);
+ }
}
protected function createMockApp(): ApplicationContract
diff --git a/tests/Events/BroadcastedEventsTest.php b/tests/Events/BroadcastedEventsTest.php
index bff61616a..f510dfbbe 100644
--- a/tests/Events/BroadcastedEventsTest.php
+++ b/tests/Events/BroadcastedEventsTest.php
@@ -4,10 +4,12 @@
namespace Hypervel\Tests\Events\BroadcastedEventsTest;
+use Hypervel\Broadcasting\PendingBroadcast;
use Hypervel\Container\Container;
use Hypervel\Contracts\Broadcasting\Factory as BroadcastFactory;
use Hypervel\Contracts\Broadcasting\ShouldBroadcast;
use Hypervel\Events\Dispatcher;
+use Hypervel\Foundation\Events\Dispatchable;
use Hypervel\Tests\TestCase;
use Mockery as m;
@@ -148,6 +150,38 @@ public function broadcastWith(): array
$d->dispatch($event);
}
+
+ public function testEventBroadcastsUsingNamedArguments(): void
+ {
+ $container = new Container;
+ $broadcast = m::mock(BroadcastFactory::class);
+ $container->instance(BroadcastFactory::class, $broadcast);
+
+ $originalContainer = Container::getInstance();
+ Container::setInstance($container);
+
+ try {
+ $pendingBroadcast = m::mock(PendingBroadcast::class);
+
+ $broadcast->shouldReceive('event')
+ ->once()
+ ->with(m::on(function ($event): bool {
+ $this->assertInstanceOf(BroadcastableNamedArgumentsEvent::class, $event);
+ $this->assertSame('first-value', $event->first);
+ $this->assertSame('second-value', $event->second);
+
+ return true;
+ }))
+ ->andReturn($pendingBroadcast);
+
+ $this->assertSame(
+ $pendingBroadcast,
+ BroadcastableNamedArgumentsEvent::broadcast(second: 'second-value', first: 'first-value')
+ );
+ } finally {
+ Container::setInstance($originalContainer);
+ }
+ }
}
class BroadcastEvent implements ShouldBroadcast
@@ -182,3 +216,14 @@ public function broadcastWhen(): bool
class ExampleEvent
{
}
+
+class BroadcastableNamedArgumentsEvent
+{
+ use Dispatchable;
+
+ public function __construct(
+ public string $first,
+ public string $second,
+ ) {
+ }
+}
diff --git a/tests/Events/CallQueuedListenerTest.php b/tests/Events/CallQueuedListenerTest.php
index 621fa0070..081a13979 100644
--- a/tests/Events/CallQueuedListenerTest.php
+++ b/tests/Events/CallQueuedListenerTest.php
@@ -4,8 +4,16 @@
namespace Hypervel\Tests\Events;
+use Hypervel\Container\Container;
use Hypervel\Events\CallQueuedListener as HypervelCallQueuedListener;
+use Hypervel\Queue\InteractsWithQueue;
+use Hypervel\Queue\Jobs\FakeJob;
use Hypervel\Tests\TestCase;
+use RuntimeException;
+use stdClass;
+use Swoole\Coroutine\Channel;
+
+use function Hypervel\Coroutine\parallel;
class CallQueuedListenerTest extends TestCase
{
@@ -16,6 +24,38 @@ public function testHypervelListenerToleratesUnknownPropertiesOnUnserialization(
);
}
+ public function testCloningCopiesObjectDataWithinTheArrayPayload(): void
+ {
+ $listener = new HypervelCallQueuedListener(stdClass::class, '__invoke', [
+ $payload = new stdClass,
+ ]);
+
+ $clone = clone $listener;
+
+ $this->assertNotSame($listener, $clone);
+ $this->assertNotSame($payload, $clone->data[0]);
+ }
+
+ public function testConcurrentQueuedListenersReceiveTheirOwnJobInstance(): void
+ {
+ $container = new Container;
+ $container->instance(QueuedListenerJobBarrier::class, new QueuedListenerJobBarrier);
+
+ $first = new HypervelCallQueuedListener(JobAwareQueuedListener::class, 'handle', ['first']);
+ $first->setJob($firstJob = new FakeJob);
+
+ $second = new HypervelCallQueuedListener(JobAwareQueuedListener::class, 'handle', ['second']);
+ $second->setJob($secondJob = new FakeJob);
+
+ parallel([
+ fn () => $first->handle($container),
+ fn () => $second->handle($container),
+ ]);
+
+ $this->assertTrue($firstJob->isDeleted());
+ $this->assertTrue($secondJob->isDeleted());
+ }
+
/**
* Simulates cross-version deserialization: a job payload serialized by a
* newer Laravel/Hypervel version (which adds extra properties to
@@ -44,3 +84,53 @@ private function assertListenerToleratesUnknownProperties(string $class): void
$this->assertSame('value', $result->newPropertyFromV11);
}
}
+
+class JobAwareQueuedListener
+{
+ use InteractsWithQueue;
+
+ public function __construct(
+ private QueuedListenerJobBarrier $barrier
+ ) {
+ }
+
+ public function handle(string $execution): void
+ {
+ if ($execution === 'first') {
+ $this->barrier->firstReady->push(true);
+ $this->barrier->waitFor($this->barrier->secondReady);
+ $this->delete();
+ $this->barrier->firstDeleted->push(true);
+
+ return;
+ }
+
+ $this->barrier->waitFor($this->barrier->firstReady);
+ $this->barrier->secondReady->push(true);
+ $this->barrier->waitFor($this->barrier->firstDeleted);
+ $this->delete();
+ }
+}
+
+class QueuedListenerJobBarrier
+{
+ public Channel $firstReady;
+
+ public Channel $secondReady;
+
+ public Channel $firstDeleted;
+
+ public function __construct()
+ {
+ $this->firstReady = new Channel(1);
+ $this->secondReady = new Channel(1);
+ $this->firstDeleted = new Channel(1);
+ }
+
+ public function waitFor(Channel $channel): void
+ {
+ if ($channel->pop(1.0) !== true) {
+ throw new RuntimeException('Timed out while coordinating queued listener jobs.');
+ }
+ }
+}
diff --git a/tests/Events/CoroutineEventsTest.php b/tests/Events/CoroutineEventsTest.php
index 55e07049a..f702f4cd4 100644
--- a/tests/Events/CoroutineEventsTest.php
+++ b/tests/Events/CoroutineEventsTest.php
@@ -5,13 +5,12 @@
namespace Hypervel\Tests\Events\CoroutineEventsTest;
use Hypervel\Context\CoroutineContext;
-use Hypervel\Coroutine\WaitGroup;
use Hypervel\Events\Dispatcher;
use Hypervel\Tests\TestCase;
use ReflectionClass;
use RuntimeException;
-use function Hypervel\Coroutine\go;
+use function Hypervel\Coroutine\parallel;
class CoroutineEventsTest extends TestCase
{
@@ -28,47 +27,40 @@ public function testDeferredEventsAreCoroutineIsolated()
$results[] = 'b-dispatched';
});
- $waiter = new WaitGroup;
-
// Track whether events were correctly deferred (not dispatched during callback)
$aDeferredDuringCallback = null;
$bDeferredDuringCallback = null;
- // Coroutine 1: defer event-a, sleep to let coroutine 2 run, then complete
- $waiter->add(1);
- go(function () use ($dispatcher, &$results, &$aDeferredDuringCallback, $waiter) {
- $dispatcher->defer(function () use ($dispatcher, &$results, &$aDeferredDuringCallback) {
- $dispatcher->dispatch('event-a');
-
- // Event should be deferred, not dispatched yet
- $aDeferredDuringCallback = ! in_array('a-dispatched', $results, true);
+ parallel([
+ // Coroutine 1: defer event-a, sleep to let coroutine 2 run, then complete
+ function () use ($dispatcher, &$results, &$aDeferredDuringCallback) {
+ $dispatcher->defer(function () use ($dispatcher, &$results, &$aDeferredDuringCallback) {
+ $dispatcher->dispatch('event-a');
- usleep(10000); // 10ms — let coroutine 2 start its defer
- });
+ // Event should be deferred, not dispatched yet
+ $aDeferredDuringCallback = ! in_array('a-dispatched', $results, true);
- // After defer() completes, event-a should have been dispatched
- $results[] = 'coroutine-1-done';
- $waiter->done();
- });
+ usleep(10000); // 10ms — let coroutine 2 start its defer
+ });
- // Coroutine 2: defer event-b independently
- $waiter->add(1);
- go(function () use ($dispatcher, &$results, &$bDeferredDuringCallback, $waiter) {
- usleep(5000); // 5ms — start after coroutine 1 enters defer
+ // After defer() completes, event-a should have been dispatched
+ $results[] = 'coroutine-1-done';
+ },
+ // Coroutine 2: defer event-b independently
+ function () use ($dispatcher, &$results, &$bDeferredDuringCallback) {
+ usleep(5000); // 5ms — start after coroutine 1 enters defer
- $dispatcher->defer(function () use ($dispatcher, &$results, &$bDeferredDuringCallback) {
- $dispatcher->dispatch('event-b');
+ $dispatcher->defer(function () use ($dispatcher, &$results, &$bDeferredDuringCallback) {
+ $dispatcher->dispatch('event-b');
- // Event should be deferred, not dispatched yet
- $bDeferredDuringCallback = ! in_array('b-dispatched', $results, true);
- });
-
- // After defer() completes, event-b should have been dispatched
- $results[] = 'coroutine-2-done';
- $waiter->done();
- });
+ // Event should be deferred, not dispatched yet
+ $bDeferredDuringCallback = ! in_array('b-dispatched', $results, true);
+ });
- $waiter->wait();
+ // After defer() completes, event-b should have been dispatched
+ $results[] = 'coroutine-2-done';
+ },
+ ]);
// Events were correctly deferred inside their respective callbacks
$this->assertTrue($aDeferredDuringCallback, 'event-a should have been deferred during callback');
@@ -95,29 +87,22 @@ public function testDeferredEventsDoNotLeakBetweenCoroutines()
}
});
- $waiter = new WaitGroup;
-
- // Coroutine 1: defer and dispatch with source=coroutine-1
- $waiter->add(1);
- go(function () use ($dispatcher, $waiter) {
- $dispatcher->defer(function () use ($dispatcher) {
- $dispatcher->dispatch('shared-event', ['coroutine-1']);
- usleep(15000); // 15ms — hold open while coroutine 2 finishes defer
- });
- $waiter->done();
- });
-
- // Coroutine 2: defer and dispatch with source=coroutine-2
- $waiter->add(1);
- go(function () use ($dispatcher, $waiter) {
- usleep(5000); // 5ms delay
- $dispatcher->defer(function () use ($dispatcher) {
- $dispatcher->dispatch('shared-event', ['coroutine-2']);
- });
- $waiter->done();
- });
-
- $waiter->wait();
+ parallel([
+ // Coroutine 1: defer and dispatch with source=coroutine-1
+ function () use ($dispatcher) {
+ $dispatcher->defer(function () use ($dispatcher) {
+ $dispatcher->dispatch('shared-event', ['coroutine-1']);
+ usleep(15000); // 15ms — hold open while coroutine 2 finishes defer
+ });
+ },
+ // Coroutine 2: defer and dispatch with source=coroutine-2
+ function () use ($dispatcher) {
+ usleep(5000); // 5ms delay
+ $dispatcher->defer(function () use ($dispatcher) {
+ $dispatcher->dispatch('shared-event', ['coroutine-2']);
+ });
+ },
+ ]);
// Each coroutine should have dispatched its own event independently
$this->assertCount(1, $coroutine1Events);
@@ -133,27 +118,20 @@ public function testPushedEventsDoNotLeakBetweenCoroutines()
$flushed[] = $source;
});
- $waiter = new WaitGroup;
+ parallel([
+ function () use ($dispatcher) {
+ $dispatcher->push('shared-event', ['coroutine-1']);
- $waiter->add(1);
- go(function () use ($dispatcher, $waiter) {
- $dispatcher->push('shared-event', ['coroutine-1']);
+ usleep(10000);
- usleep(10000);
+ $dispatcher->flush('shared-event');
+ },
+ function () use ($dispatcher) {
+ usleep(5000);
- $dispatcher->flush('shared-event');
- $waiter->done();
- });
-
- $waiter->add(1);
- go(function () use ($dispatcher, $waiter) {
- usleep(5000);
-
- $dispatcher->push('shared-event', ['coroutine-2']);
- $waiter->done();
- });
-
- $waiter->wait();
+ $dispatcher->push('shared-event', ['coroutine-2']);
+ },
+ ]);
$this->assertSame(['coroutine-1'], $flushed);
}
@@ -167,28 +145,21 @@ public function testForgetPushedDoesNotClearOtherCoroutinePushedEvents()
$flushed[] = $source;
});
- $waiter = new WaitGroup;
-
- $waiter->add(1);
- go(function () use ($dispatcher, $waiter) {
- $dispatcher->push('shared-event', ['coroutine-1']);
-
- usleep(10000);
+ parallel([
+ function () use ($dispatcher) {
+ $dispatcher->push('shared-event', ['coroutine-1']);
- $dispatcher->flush('shared-event');
- $waiter->done();
- });
-
- $waiter->add(1);
- go(function () use ($dispatcher, $waiter) {
- usleep(5000);
+ usleep(10000);
- $dispatcher->push('shared-event', ['coroutine-2']);
- $dispatcher->forgetPushed();
- $waiter->done();
- });
+ $dispatcher->flush('shared-event');
+ },
+ function () use ($dispatcher) {
+ usleep(5000);
- $waiter->wait();
+ $dispatcher->push('shared-event', ['coroutine-2']);
+ $dispatcher->forgetPushed();
+ },
+ ]);
$this->assertSame(['coroutine-1'], $flushed);
}
diff --git a/tests/Events/EventsDispatcherTest.php b/tests/Events/EventsDispatcherTest.php
index 74b8ec98c..e4f1b4c8f 100755
--- a/tests/Events/EventsDispatcherTest.php
+++ b/tests/Events/EventsDispatcherTest.php
@@ -9,6 +9,7 @@
use Hypervel\Container\Container;
use Hypervel\Contracts\Queue\ShouldQueue;
use Hypervel\Events\Dispatcher;
+use Hypervel\Foundation\Events\Dispatchable;
use Hypervel\Tests\Events\Fixtures\CacheInvalidationEvent;
use Hypervel\Tests\Events\Fixtures\ChildListenerEvent;
use Hypervel\Tests\Events\Fixtures\InterfaceListenerEvent;
@@ -20,6 +21,7 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use ReflectionProperty;
+use RuntimeException;
class EventsDispatcherTest extends TestCase
{
@@ -1279,6 +1281,50 @@ public function testClassArrayObserverIsAlwaysSynchronous()
$this->assertTrue(TestQueueableObserver::$invoked);
}
+
+ public function testEventDispatchesUsingNamedArguments(): void
+ {
+ $events = m::mock(Dispatcher::class);
+ Container::getInstance()->instance('events', $events);
+
+ $events->shouldReceive('dispatch')
+ ->once()
+ ->with(m::on(function ($event): bool {
+ $this->assertInstanceOf(DispatchableNamedArgumentsEvent::class, $event);
+ $this->assertSame('first-value', $event->first);
+ $this->assertSame('second-value', $event->second);
+
+ return true;
+ }))
+ ->andReturn(['dispatched']);
+
+ $this->assertSame(
+ ['dispatched'],
+ DispatchableNamedArgumentsEvent::dispatch(second: 'second-value', first: 'first-value')
+ );
+ }
+
+ public function testClosureListenerRejectsAnUntypedFirstParameterInsteadOfUsingALaterType(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('The first parameter of the given Closure is missing a type hint.');
+
+ (new Dispatcher)->listen(function ($untyped, ExampleEvent $event): void {});
+ }
+
+ public function testClosureListenerRegistersEveryTypeFromAFirstParameterUnion(): void
+ {
+ $events = [];
+ $dispatcher = new Dispatcher;
+ $dispatcher->listen(function (ExampleEvent|DeferTestEvent $event) use (&$events): void {
+ $events[] = $event;
+ });
+
+ $dispatcher->dispatch($first = new ExampleEvent);
+ $dispatcher->dispatch($second = new DeferTestEvent);
+
+ $this->assertSame([$first, $second], $events);
+ }
}
class TestListenerLean
@@ -1427,6 +1473,17 @@ class ImmediateTestEvent
{
}
+class DispatchableNamedArgumentsEvent
+{
+ use Dispatchable;
+
+ public function __construct(
+ public string $first,
+ public string $second,
+ ) {
+ }
+}
+
class TestQueueableObserver implements ShouldQueue
{
public static bool $invoked = false;
diff --git a/tests/Events/QueuedEventsTest.php b/tests/Events/QueuedEventsTest.php
index aecad731a..47cc614d7 100644
--- a/tests/Events/QueuedEventsTest.php
+++ b/tests/Events/QueuedEventsTest.php
@@ -16,6 +16,7 @@
use Hypervel\Contracts\Queue\ShouldQueue;
use Hypervel\Events\CallQueuedListener;
use Hypervel\Events\Dispatcher;
+use Hypervel\Queue\Attributes\Backoff;
use Hypervel\Queue\Attributes\Delay;
use Hypervel\Queue\CallQueuedHandler;
use Hypervel\Queue\InteractsWithQueue;
@@ -45,6 +46,25 @@ public function testQueuedEventHandlersAreQueued()
$d->dispatch('some.event', ['foo', 'bar']);
}
+ public function testQueuedEnumEventsRemainSerializable(): void
+ {
+ $dispatcher = new Dispatcher;
+ $queue = new QueueFake(new Container);
+
+ $dispatcher->setQueueResolver(fn () => $queue);
+ $dispatcher->listen(QueuedEvent::class, TestDispatcherQueuedHandler::class . '@handle');
+ $dispatcher->dispatch(QueuedEvent::Created);
+
+ $queue->assertPushed(CallQueuedListener::class, function (CallQueuedListener $job): bool {
+ $clone = clone $job;
+
+ $this->assertSame(QueuedEvent::Created, $clone->data[0]);
+ $this->assertIsString(serialize($clone));
+
+ return true;
+ });
+ }
+
public function testCustomizedQueuedEventHandlersAreQueued()
{
$d = new Dispatcher;
@@ -263,6 +283,36 @@ public function testQueuePropagateTries()
});
}
+ public function testQueuePropagatesArrayBackoffFromMethod(): void
+ {
+ $dispatcher = new Dispatcher;
+ $queue = new QueueFake(new Container);
+
+ $dispatcher->setQueueResolver(fn () => $queue);
+ $dispatcher->listen('some.event', TestDispatcherWithBackoffMethod::class . '@handle');
+ $dispatcher->dispatch('some.event', ['foo', 'bar']);
+
+ $queue->assertPushed(
+ CallQueuedListener::class,
+ fn (CallQueuedListener $job): bool => $job->backoff === [1, 5, 10]
+ );
+ }
+
+ public function testQueuePropagatesVariadicBackoffAttribute(): void
+ {
+ $dispatcher = new Dispatcher;
+ $queue = new QueueFake(new Container);
+
+ $dispatcher->setQueueResolver(fn () => $queue);
+ $dispatcher->listen('some.event', TestDispatcherWithBackoffAttribute::class . '@handle');
+ $dispatcher->dispatch('some.event', ['foo', 'bar']);
+
+ $queue->assertPushed(
+ CallQueuedListener::class,
+ fn (CallQueuedListener $job): bool => $job->backoff === [1, 5, 10]
+ );
+ }
+
public function testQueuePropagateMessageGroupProperty()
{
$d = new Dispatcher;
@@ -490,7 +540,7 @@ public function testUniqueLockKeyUsesListenerClassName()
$this->assertSame(TestDispatcherShouldBeUnique::class, $listener->displayName());
$this->assertSame(
- 'laravel_unique_job:' . TestDispatcherShouldBeUnique::class . ':test-id',
+ 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUnique::class) . ':test-id',
\Hypervel\Bus\UniqueLock::getKey($listener)
);
}
@@ -506,7 +556,7 @@ public function testUniqueLockIsAcquiredWithListenerClassName()
$container->instance(Cache::class, $cache);
- $expectedKey = 'laravel_unique_job:' . TestDispatcherShouldBeUnique::class . ':unique-listener-id';
+ $expectedKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUnique::class) . ':unique-listener-id';
$cache->shouldReceive('lock')
->once()
@@ -540,7 +590,7 @@ public function testUniqueViaUsesListenerCacheRepository()
TestDispatcherShouldBeUniqueWithCustomCache::$cache = $uniqueCache;
- $expectedKey = 'laravel_unique_job:' . TestDispatcherShouldBeUniqueWithCustomCache::class . ':unique-listener-id';
+ $expectedKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueWithCustomCache::class) . ':unique-listener-id';
$uniqueCache->shouldReceive('lock')
->once()
@@ -572,7 +622,7 @@ public function testUniqueLockIsReleasedOnProcessingWithListenerClassName()
$listener->uniqueId = 'unique-listener-id';
$listener->uniqueFor = 60;
- $expectedKey = 'laravel_unique_job:' . TestDispatcherShouldBeUnique::class . ':unique-listener-id';
+ $expectedKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUnique::class) . ':unique-listener-id';
$cache->shouldReceive('lock')
->once()
@@ -602,14 +652,14 @@ public function testUniqueUntilProcessingLockIsReleasedBeforeHandling()
TestDispatcherShouldBeUniqueUntilProcessing::$lockReleasedBeforeHandling = null;
TestDispatcherShouldBeUniqueUntilProcessing::$cache = $cache;
- TestDispatcherShouldBeUniqueUntilProcessing::$expectedLockKey = 'laravel_unique_job:' . TestDispatcherShouldBeUniqueUntilProcessing::class . ':until-processing-id';
+ TestDispatcherShouldBeUniqueUntilProcessing::$expectedLockKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueUntilProcessing::class) . ':until-processing-id';
$listener = new CallQueuedListener(TestDispatcherShouldBeUniqueUntilProcessing::class, 'handle', ['foo', 'bar']);
$listener->shouldBeUnique = true;
$listener->shouldBeUniqueUntilProcessing = true;
$listener->uniqueId = 'until-processing-id';
- $expectedKey = 'laravel_unique_job:' . TestDispatcherShouldBeUniqueUntilProcessing::class . ':until-processing-id';
+ $expectedKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueUntilProcessing::class) . ':until-processing-id';
$cache->shouldReceive('lock')
->with($expectedKey)
@@ -638,6 +688,11 @@ public function handle()
}
}
+enum QueuedEvent
+{
+ case Created;
+}
+
class TestDispatcherConnectionQueuedHandler implements ShouldQueue
{
public string $connection = 'redis';
@@ -733,6 +788,26 @@ public function handle()
}
}
+class TestDispatcherWithBackoffMethod implements ShouldQueue
+{
+ public function backoff(): array
+ {
+ return [1, 5, 10];
+ }
+
+ public function handle(): void
+ {
+ }
+}
+
+#[Backoff(1, 5, 10)]
+class TestDispatcherWithBackoffAttribute implements ShouldQueue
+{
+ public function handle(): void
+ {
+ }
+}
+
class TestDispatcherWithMessageGroupProperty implements ShouldQueue
{
public string $messageGroup = 'group-property';
diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php
index 40dcbc72e..974639f7c 100755
--- a/tests/Filesystem/FilesystemTest.php
+++ b/tests/Filesystem/FilesystemTest.php
@@ -626,6 +626,26 @@ public function testMakeDirectory()
$this->assertFileExists($this->tempDir . '/created');
}
+ public function testEnsureDirectoryExistsThrowsWhenCreationFails(): void
+ {
+ $path = $this->tempDir . '/uncreatable';
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("Unable to create directory [{$path}].");
+
+ (new FailingDirectoryFilesystem)->ensureDirectoryExists($path);
+ }
+
+ public function testEnsureDirectoryExistsAcceptsAConcurrentCreator(): void
+ {
+ $filesystem = new ConcurrentDirectoryFilesystem;
+
+ $filesystem->ensureDirectoryExists($this->tempDir . '/concurrently-created');
+
+ $this->assertSame(2, $filesystem->directoryChecks);
+ $this->assertSame(1, $filesystem->creationAttempts);
+ }
+
public function testRequireOnceRequiresFileProperly()
{
$filesystem = new Filesystem;
@@ -827,3 +847,35 @@ public function isFile(string $file): bool
return true;
}
}
+
+class FailingDirectoryFilesystem extends Filesystem
+{
+ public function isDirectory(string $directory): bool
+ {
+ return false;
+ }
+
+ public function makeDirectory(string $path, int $mode = 0755, bool $recursive = false, bool $force = false): bool
+ {
+ return false;
+ }
+}
+
+class ConcurrentDirectoryFilesystem extends Filesystem
+{
+ public int $directoryChecks = 0;
+
+ public int $creationAttempts = 0;
+
+ public function isDirectory(string $directory): bool
+ {
+ return ++$this->directoryChecks > 1;
+ }
+
+ public function makeDirectory(string $path, int $mode = 0755, bool $recursive = false, bool $force = false): bool
+ {
+ ++$this->creationAttempts;
+
+ return false;
+ }
+}
diff --git a/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php b/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php
index 563b3e485..efc6fdc48 100644
--- a/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php
+++ b/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php
@@ -9,16 +9,16 @@
use Hypervel\Di\Aop\AbstractAspect;
use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Di\Aop\ProceedingJoinPoint;
+use Hypervel\Di\Aop\ProxyMarker;
use Hypervel\Foundation\Testing\Concerns\InteractsWithAop;
use Hypervel\Testbench\TestCase;
-use Hypervel\Tests\Di\Fixtures\ProxyTraitObject;
use LogicException;
class InteractsWithAopTest extends TestCase
{
use InteractsWithAop;
- public function testCallWithAspectsRunsMatchingAspect()
+ public function testCallWithAspectsRunsMatchingAspect(): void
{
AspectCollector::setAround(UppercaseGreetingAspect::class, [InteractsWithAopTarget::class . '::greet']);
@@ -27,14 +27,14 @@ public function testCallWithAspectsRunsMatchingAspect()
$this->assertSame('HELLO HYPERVEL', $result);
}
- public function testCallWithAspectsReturnsOriginalResultWhenNoAspectMatches()
+ public function testCallWithAspectsReturnsOriginalResultWhenNoAspectMatches(): void
{
$result = $this->callWithAspects(new InteractsWithAopTarget, 'greet', ['name' => 'hypervel']);
$this->assertSame('hello hypervel', $result);
}
- public function testCallWithAspectsRespectsAspectPriority()
+ public function testCallWithAspectsRespectsAspectPriority(): void
{
AspectCollector::setAround(OuterSequenceAspect::class, [InteractsWithAopTarget::class . '::sequence'], 20);
AspectCollector::setAround(InnerSequenceAspect::class, [InteractsWithAopTarget::class . '::sequence'], 10);
@@ -44,7 +44,7 @@ public function testCallWithAspectsRespectsAspectPriority()
$this->assertSame('outer(inner(core))', $result);
}
- public function testCallWithAspectsPassesBoundInstanceToAspect()
+ public function testCallWithAspectsPassesBoundInstanceToAspect(): void
{
AspectCollector::setAround(UsesInstanceNameAspect::class, [InteractsWithAopTarget::class . '::instanceName']);
@@ -53,7 +53,7 @@ public function testCallWithAspectsPassesBoundInstanceToAspect()
$this->assertSame('Hypervel', $result);
}
- public function testCallWithAspectsFillsOmittedOptionalParametersFromDefaults()
+ public function testCallWithAspectsFillsOmittedOptionalParametersFromDefaults(): void
{
AspectCollector::setAround(CaptureArgumentsAspect::class, [InteractsWithAopTarget::class . '::withDefaults']);
@@ -62,7 +62,7 @@ public function testCallWithAspectsFillsOmittedOptionalParametersFromDefaults()
$this->assertSame([1, 'default'], $result);
}
- public function testCallWithAspectsHandlesVariadicParameters()
+ public function testCallWithAspectsHandlesVariadicParameters(): void
{
AspectCollector::setAround(CaptureArgumentsAspect::class, [InteractsWithAopTarget::class . '::withVariadic']);
@@ -74,7 +74,7 @@ public function testCallWithAspectsHandlesVariadicParameters()
$this->assertSame([2, 'alpha', 'beta'], $result);
}
- public function testCallWithAspectsSupportsExactMethodRules()
+ public function testCallWithAspectsSupportsExactMethodRules(): void
{
AspectCollector::setAround(AppendSuffixAspect::class, [InteractsWithAopTarget::class . '::intercepted']);
@@ -84,7 +84,7 @@ public function testCallWithAspectsSupportsExactMethodRules()
$this->assertSame('untouched', $this->callWithAspects($target, 'untouched'));
}
- public function testCallWithAspectsSupportsNonPublicMethods()
+ public function testCallWithAspectsSupportsNonPublicMethods(): void
{
AspectCollector::setAround(AppendSuffixAspect::class, [InteractsWithAopTarget::class . '::hiddenValue']);
@@ -93,21 +93,22 @@ public function testCallWithAspectsSupportsNonPublicMethods()
$this->assertSame('secret [aspect]', $result);
}
- public function testCallWithAspectsThrowsForAlreadyProxiedInstances()
+ public function testCallWithAspectsThrowsForAlreadyProxiedInstances(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('already proxied by AOP');
- $this->callWithAspects(new ProxyTraitObject, 'get');
+ $this->callWithAspects(new InteractsWithAopProxiedTarget, 'get');
}
- public function testIsAopProxiedDetectsGeneratedProxyTraitUsage()
+ public function testIsAopProxiedDetectsGeneratedProxyMarkerUsage(): void
{
$this->assertFalse($this->isAopProxied(new InteractsWithAopTarget));
- $this->assertTrue($this->isAopProxied(new ProxyTraitObject));
+ $this->assertTrue($this->isAopProxied(new InteractsWithAopProxiedTarget));
+ $this->assertTrue($this->isAopProxied(new InteractsWithAopInheritedProxyTarget));
}
- public function testCallWithAspectsPreservesPromiseReturnValues()
+ public function testCallWithAspectsPreservesPromiseReturnValues(): void
{
AspectCollector::setAround(PassThroughAspect::class, [InteractsWithAopTarget::class . '::promiseResult']);
@@ -118,6 +119,20 @@ public function testCallWithAspectsPreservesPromiseReturnValues()
}
}
+class InteractsWithAopProxiedTarget
+{
+ use ProxyMarker;
+
+ public function get(): string
+ {
+ return 'value';
+ }
+}
+
+class InteractsWithAopInheritedProxyTarget extends InteractsWithAopProxiedTarget
+{
+}
+
class InteractsWithAopTarget
{
public function __construct(public string $name = 'Hypervel')
diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php
index 57b338942..45ba4252a 100644
--- a/tests/Integration/Broadcasting/BroadcastManagerTest.php
+++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php
@@ -98,7 +98,7 @@ public function testUniqueEventsCanBeBroadcast(): void
Bus::fake();
Queue::fake();
- $lockKey = 'laravel_unique_job:' . TestEventUnique::class . ':';
+ $lockKey = 'laravel_unique_job:' . hash('xxh128', TestEventUnique::class) . ':';
$lock = m::mock(\Hypervel\Contracts\Cache\Lock::class);
$lock->shouldReceive('get')->once()->andReturn(true);
$cache = m::mock(Cache::class);
@@ -121,7 +121,7 @@ public function testUniqueEventsCanBeBroadcastWithUniqueIdFromProperty(): void
Bus::assertNotDispatched(UniqueBroadcastEvent::class);
Queue::assertPushed(UniqueBroadcastEvent::class);
- $lockKey = 'laravel_unique_job:' . TestEventUniqueWithIdProperty::class . ':unique-id-property';
+ $lockKey = 'laravel_unique_job:' . hash('xxh128', TestEventUniqueWithIdProperty::class) . ':unique-id-property';
$this->assertFalse($this->app->get(Cache::class)->lock($lockKey, 10)->get());
}
@@ -135,7 +135,7 @@ public function testUniqueEventsCanBeBroadcastWithUniqueIdFromMethod(): void
Bus::assertNotDispatched(UniqueBroadcastEvent::class);
Queue::assertPushed(UniqueBroadcastEvent::class);
- $lockKey = 'laravel_unique_job:' . TestEventUniqueWithIdMethod::class . ':unique-id-method';
+ $lockKey = 'laravel_unique_job:' . hash('xxh128', TestEventUniqueWithIdMethod::class) . ':unique-id-method';
$this->assertFalse($this->app->get(Cache::class)->lock($lockKey, 10)->get());
}
diff --git a/tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php b/tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php
index 16fcb0308..890091926 100644
--- a/tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php
+++ b/tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php
@@ -140,6 +140,49 @@ public function testObserverIsCalledEvenWhenDeeplyNestingTransactions()
$this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.');
}
+ public function testAfterCommitObserverCreatingEventFiresImmediately(): void
+ {
+ User::observe($observer = EloquentTransactionWithAfterCommitTestsCreatingObserver::resetting());
+
+ DB::transaction(function () use ($observer): void {
+ User::unguarded(fn () => User::create(UserFactory::new()->raw()));
+
+ $this->assertTrue($observer::$creatingCalled, 'creating should fire immediately inside the transaction');
+ $this->assertFalse($observer::$createdCalled, 'created should not fire until after commit');
+ });
+
+ $this->assertTrue($observer::$createdCalled, 'created should fire after commit');
+ }
+
+ public function testAfterCommitObserverUpdatingEventFiresImmediately(): void
+ {
+ User::observe($observer = EloquentTransactionWithAfterCommitTestsUpdatingObserver::resetting());
+
+ $user = User::unguarded(fn () => User::create(UserFactory::new()->raw()));
+
+ DB::transaction(function () use ($user, $observer): void {
+ User::unguarded(fn () => $user->update(['name' => 'Updated Name']));
+
+ $this->assertTrue($observer::$updatingCalled, 'updating should fire immediately inside the transaction');
+ $this->assertFalse($observer::$updatedCalled, 'updated should not fire until after commit');
+ });
+
+ $this->assertTrue($observer::$updatedCalled, 'updated should fire after commit');
+ }
+
+ public function testAfterCommitObserverCreatingCanCancelOperation(): void
+ {
+ User::observe($observer = EloquentTransactionWithAfterCommitTestsCancellingObserver::resetting());
+
+ $user = DB::transaction(
+ fn () => User::unguarded(fn () => User::create(UserFactory::new()->raw()))
+ );
+
+ $this->assertFalse($user->exists, 'Model should not be persisted when creating returns false');
+ $this->assertTrue($observer::$creatingCalled, 'creating should have been called');
+ $this->assertFalse($observer::$createdCalled, 'created should not fire when creating was cancelled');
+ }
+
public function testTransactionCallbackExceptions()
{
[$firstObject, $secondObject] = [
@@ -243,3 +286,86 @@ public function handle(): void
++$this->runs;
}
}
+
+class EloquentTransactionWithAfterCommitTestsCreatingObserver
+{
+ public static bool $creatingCalled = false;
+
+ public static bool $createdCalled = false;
+
+ public bool $afterCommit = true;
+
+ public static function resetting(): static
+ {
+ static::$creatingCalled = false;
+ static::$createdCalled = false;
+
+ return new static;
+ }
+
+ public function creating(User $user): void
+ {
+ static::$creatingCalled = true;
+ }
+
+ public function created(User $user): void
+ {
+ static::$createdCalled = true;
+ }
+}
+
+class EloquentTransactionWithAfterCommitTestsUpdatingObserver
+{
+ public static bool $updatingCalled = false;
+
+ public static bool $updatedCalled = false;
+
+ public bool $afterCommit = true;
+
+ public static function resetting(): static
+ {
+ static::$updatingCalled = false;
+ static::$updatedCalled = false;
+
+ return new static;
+ }
+
+ public function updating(User $user): void
+ {
+ static::$updatingCalled = true;
+ }
+
+ public function updated(User $user): void
+ {
+ static::$updatedCalled = true;
+ }
+}
+
+class EloquentTransactionWithAfterCommitTestsCancellingObserver
+{
+ public static bool $creatingCalled = false;
+
+ public static bool $createdCalled = false;
+
+ public bool $afterCommit = true;
+
+ public static function resetting(): static
+ {
+ static::$creatingCalled = false;
+ static::$createdCalled = false;
+
+ return new static;
+ }
+
+ public function creating(User $user): bool
+ {
+ static::$creatingCalled = true;
+
+ return false;
+ }
+
+ public function created(User $user): void
+ {
+ static::$createdCalled = true;
+ }
+}
diff --git a/tests/Integration/Events/QueuedClosureListenerTest.php b/tests/Integration/Events/QueuedClosureListenerTest.php
index fb2204104..bce707bc6 100644
--- a/tests/Integration/Events/QueuedClosureListenerTest.php
+++ b/tests/Integration/Events/QueuedClosureListenerTest.php
@@ -45,6 +45,20 @@ public function testAnonymousQueuedListenerIsQueuedOnMessageGroup()
});
}
+ public function testAnonymousQueuedListenerAcceptsAnIntBackedEnumMessageGroup(): void
+ {
+ Bus::fake();
+
+ Event::listen(\Hypervel\Events\queueable(function (TestEvent $event): void {
+ })->onGroup(TestMessageGroup::First));
+
+ Event::dispatch(new TestEvent);
+
+ Bus::assertDispatched(CallQueuedListener::class, function ($job): bool {
+ return $job->messageGroup === TestMessageGroup::First->value;
+ });
+ }
+
public function testAnonymousQueuedListenerIsQueuedWithDeduplicator()
{
$deduplicator = fn ($payload, $queue) => 'deduplicator-1';
@@ -63,8 +77,39 @@ public function testAnonymousQueuedListenerIsQueuedWithDeduplicator()
return is_callable($job->deduplicator) && call_user_func($job->deduplicator, '', null) === 'deduplicator-1';
});
}
+
+ public function testAnonymousQueuedListenerAcceptsAnArrayCallableDeduplicator(): void
+ {
+ $deduplicator = [new QueuedClosureDeduplicator, 'resolve'];
+
+ Bus::fake();
+
+ Event::listen(\Hypervel\Events\queueable(function (TestEvent $event): void {
+ })->withDeduplicator($deduplicator));
+
+ Event::dispatch(new TestEvent);
+
+ Bus::assertDispatched(CallQueuedListener::class, function ($job): bool {
+ $this->assertIsCallable($job->deduplicator);
+
+ return call_user_func($job->deduplicator, '', null) === 'array-deduplicator';
+ });
+ }
}
class TestEvent
{
}
+
+enum TestMessageGroup: int
+{
+ case First = 1;
+}
+
+class QueuedClosureDeduplicator
+{
+ public function resolve(string $payload, mixed $queue): string
+ {
+ return 'array-deduplicator';
+ }
+}
diff --git a/tests/Integration/Queue/JobChainingTest.php b/tests/Integration/Queue/JobChainingTest.php
index f521e1aec..bdfe998dd 100644
--- a/tests/Integration/Queue/JobChainingTest.php
+++ b/tests/Integration/Queue/JobChainingTest.php
@@ -77,6 +77,32 @@ public function testJobsCanBeChainedOnSuccessUsingPendingChain()
$this->assertTrue(JobChainingTestSecondJob::$ran);
}
+ public function testPendingChainDispatchForwardsNamedArguments(): void
+ {
+ Bus::chain([
+ JobChainingNamedTestJob::class,
+ new JobChainingNamedTestJob('second'),
+ ])->dispatch(id: 'first');
+
+ $this->runQueueWorkerCommand(['--stop-when-empty' => true]);
+
+ $this->assertSame(['first', 'second'], JobRunRecorder::$results);
+ }
+
+ public function testFakePendingChainDispatchForwardsNamedArguments(): void
+ {
+ Bus::fake();
+
+ Bus::chain([
+ JobChainingNamedTestJob::class,
+ ])->dispatch(id: 'first');
+
+ Bus::assertDispatched(
+ JobChainingNamedTestJob::class,
+ fn (JobChainingNamedTestJob $job): bool => $job->id === 'first'
+ );
+ }
+
public function testJobsCanBeChainedOnSuccessUsingBusFacade()
{
Bus::dispatchChain([
diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php
index 549bb74d9..96d27cab2 100644
--- a/tests/Integration/Queue/UniqueJobTest.php
+++ b/tests/Integration/Queue/UniqueJobTest.php
@@ -204,7 +204,7 @@ public function testLockUsesDisplayNameWhenAvailable()
{
Bus::fake();
- $lockKey = 'laravel_unique_job:App\Actions\UniqueTestAction:';
+ $lockKey = 'laravel_unique_job:' . hash('xxh128', 'App\Actions\UniqueTestAction') . ':';
dispatch(new UniqueTestJobWithDisplayName);
$this->runQueueWorkerCommand(['--once' => true]);
@@ -243,7 +243,7 @@ public function testUniqueLockCreatesKeyWithIdAndClassName()
public function testUniqueLockCreatesKeyWithDisplayNameWhenAvailable()
{
$this->assertEquals(
- 'laravel_unique_job:App\Actions\UniqueTestAction:unique-id-2',
+ 'laravel_unique_job:' . hash('xxh128', 'App\Actions\UniqueTestAction') . ':unique-id-2',
UniqueLock::getKey(new UniqueIdTestJobWithDisplayName)
);
}
@@ -251,7 +251,7 @@ public function testUniqueLockCreatesKeyWithDisplayNameWhenAvailable()
public function testUniqueLockCreatesKeyWithIdAndDisplayNameWhenAvailable()
{
$this->assertEquals(
- 'laravel_unique_job:App\Actions\UniqueTestAction:unique-id-2',
+ 'laravel_unique_job:' . hash('xxh128', 'App\Actions\UniqueTestAction') . ':unique-id-2',
UniqueLock::getKey(new UniqueIdTestJobWithDisplayName)
);
}
diff --git a/tests/Integration/Queue/WithoutOverlappingJobsTest.php b/tests/Integration/Queue/WithoutOverlappingJobsTest.php
index b6f4bf34a..c677ea768 100644
--- a/tests/Integration/Queue/WithoutOverlappingJobsTest.php
+++ b/tests/Integration/Queue/WithoutOverlappingJobsTest.php
@@ -160,7 +160,7 @@ public function testGetLockUsesDisplayName()
$job = new OverlappingTestJobWithDisplayName;
$this->assertSame(
- 'laravel-queue-overlap:App\Actions\WithoutOverlappingTestAction:key',
+ 'laravel-queue-overlap:' . hash('xxh128', 'App\Actions\WithoutOverlappingTestAction') . ':key',
(new WithoutOverlapping('key'))->getLockKey($job)
);
@@ -170,7 +170,7 @@ public function testGetLockUsesDisplayName()
);
$this->assertSame(
- 'prefix:App\Actions\WithoutOverlappingTestAction:key',
+ 'prefix:' . hash('xxh128', 'App\Actions\WithoutOverlappingTestAction') . ':key',
(new WithoutOverlapping('key'))->withPrefix('prefix:')->getLockKey($job)
);
diff --git a/tests/Queue/CallQueuedHandlerTest.php b/tests/Queue/CallQueuedHandlerTest.php
index 9c06435b2..be79464c6 100644
--- a/tests/Queue/CallQueuedHandlerTest.php
+++ b/tests/Queue/CallQueuedHandlerTest.php
@@ -6,7 +6,9 @@
use Exception;
use Hypervel\Bus\BatchRepository;
+use Hypervel\Bus\Dispatcher as ConcreteBusDispatcher;
use Hypervel\Bus\Queueable;
+use Hypervel\Container\Container;
use Hypervel\Contracts\Bus\Dispatcher as BusDispatcher;
use Hypervel\Contracts\Cache\Lock;
use Hypervel\Contracts\Cache\Repository as Cache;
@@ -18,10 +20,14 @@
use Hypervel\Events\CallQueuedListener;
use Hypervel\Queue\CallQueuedHandler;
use Hypervel\Queue\InteractsWithQueue;
+use Hypervel\Queue\Jobs\FakeJob;
use Hypervel\Tests\TestCase;
use Mockery as m;
use ReflectionMethod;
use RuntimeException;
+use Swoole\Coroutine\Channel;
+
+use function Hypervel\Coroutine\parallel;
class CallQueuedHandlerTest extends TestCase
{
@@ -277,6 +283,57 @@ public function testRunningCommandStaysNullForDebouncedJobs(): void
$this->assertNull($handler->getRunningCommand());
}
+ public function testConcurrentMappedHandlersReceiveTheirOwnJobInstance(): void
+ {
+ $container = new Container;
+ $dispatcher = new ConcreteBusDispatcher($container);
+ $dispatcher->map([
+ CallQueuedHandlerTestMappedCommand::class => CallQueuedHandlerTestMappedHandler::class,
+ ]);
+ $handler = new CallQueuedHandler($dispatcher, $container);
+
+ $firstReady = new Channel(1);
+ $secondReady = new Channel(1);
+ $firstJob = new FakeJob;
+ $secondJob = new FakeJob;
+
+ [$firstResolved, $secondResolved] = parallel([
+ function () use ($handler, $firstJob, $firstReady, $secondReady) {
+ $resolved = $this->invokeMethod(
+ $handler,
+ 'resolveHandler',
+ [$firstJob, new CallQueuedHandlerTestMappedCommand]
+ );
+
+ $firstReady->push(true);
+
+ if ($secondReady->pop(1.0) !== true) {
+ throw new RuntimeException('Timed out waiting for the second mapped handler.');
+ }
+
+ return $resolved;
+ },
+ function () use ($handler, $secondJob, $firstReady, $secondReady) {
+ if ($firstReady->pop(1.0) !== true) {
+ throw new RuntimeException('Timed out waiting for the first mapped handler.');
+ }
+
+ $resolved = $this->invokeMethod(
+ $handler,
+ 'resolveHandler',
+ [$secondJob, new CallQueuedHandlerTestMappedCommand]
+ );
+ $secondReady->push(true);
+
+ return $resolved;
+ },
+ ]);
+
+ $this->assertNotSame($firstResolved, $secondResolved);
+ $this->assertSame($firstJob, $firstResolved->job);
+ $this->assertSame($secondJob, $secondResolved->job);
+ }
+
private function createHandler(): CallQueuedHandler
{
return new CallQueuedHandler(
@@ -323,6 +380,15 @@ public function handle(): void
}
}
+class CallQueuedHandlerTestMappedCommand
+{
+}
+
+class CallQueuedHandlerTestMappedHandler
+{
+ use InteractsWithQueue;
+}
+
class CallQueuedHandlerTestRunningCommandJob implements ShouldQueue
{
public static ?CallQueuedHandler $handler = null;
diff --git a/tests/Queue/LaravelInteropTest.php b/tests/Queue/LaravelInteropTest.php
index 34fc2043d..5dabd31ee 100644
--- a/tests/Queue/LaravelInteropTest.php
+++ b/tests/Queue/LaravelInteropTest.php
@@ -38,6 +38,21 @@ public function testWithoutOverlappingPrefixMatchesLaravel()
$this->assertSame('laravel-queue-overlap:', $middleware->prefix);
}
+ public function testWithoutOverlappingDisplayNameKeyMatchesLaravel(): void
+ {
+ $job = new class {
+ public function displayName(): string
+ {
+ return 'App\Jobs\InteropJob';
+ }
+ };
+
+ $this->assertSame(
+ 'laravel-queue-overlap:' . hash('xxh128', 'App\Jobs\InteropJob') . ':test',
+ (new WithoutOverlapping('test'))->getLockKey($job)
+ );
+ }
+
public function testThrottlesExceptionsPrefixMatchesLaravel()
{
$middleware = new ThrottlesExceptions;
@@ -47,18 +62,24 @@ public function testThrottlesExceptionsPrefixMatchesLaravel()
$this->assertSame('laravel_throttles_exceptions:', $reflection->getValue($middleware));
}
- public function testUniqueLockKeyPrefixMatchesLaravel()
+ public function testUniqueLockDisplayNameKeyMatchesLaravel(): void
{
$job = new class implements ShouldQueue, ShouldBeUnique {
public function uniqueId(): string
{
return 'test-id';
}
- };
- $key = UniqueLock::getKey($job);
+ public function displayName(): string
+ {
+ return 'App\Jobs\InteropJob';
+ }
+ };
- $this->assertStringStartsWith('laravel_unique_job:', $key);
+ $this->assertSame(
+ 'laravel_unique_job:' . hash('xxh128', 'App\Jobs\InteropJob') . ':test-id',
+ UniqueLock::getKey($job)
+ );
}
public function testCallQueuedHandlerClassAliasIsRegistered()
diff --git a/tests/Queue/ReadsQueueAttributesTest.php b/tests/Queue/ReadsQueueAttributesTest.php
index 779ee546d..c9960616e 100644
--- a/tests/Queue/ReadsQueueAttributesTest.php
+++ b/tests/Queue/ReadsQueueAttributesTest.php
@@ -52,6 +52,13 @@ public function testBackoffAttributeIsRead(): void
$this->assertSame(30, $this->getAttributeValue($job, Backoff::class));
}
+ public function testBackoffAttributeWithNamedScalarIsRead(): void
+ {
+ $job = new BackoffNamedScalarJob;
+
+ $this->assertSame(30, $this->getAttributeValue($job, Backoff::class));
+ }
+
public function testBackoffAttributeWithArrayIsRead(): void
{
$job = new BackoffArrayJob;
@@ -59,6 +66,20 @@ public function testBackoffAttributeWithArrayIsRead(): void
$this->assertSame([10, 20, 30], $this->getAttributeValue($job, Backoff::class));
}
+ public function testBackoffAttributeWithNamedArrayIsRead(): void
+ {
+ $job = new BackoffNamedArrayJob;
+
+ $this->assertSame([10, 20, 30], $this->getAttributeValue($job, Backoff::class));
+ }
+
+ public function testBackoffAttributeWithVariadicValuesIsRead(): void
+ {
+ $job = new BackoffVariadicJob;
+
+ $this->assertSame([10, 20, 30], $this->getAttributeValue($job, Backoff::class));
+ }
+
public function testConnectionAttributeIsRead(): void
{
$job = new ConnectionJob;
@@ -219,11 +240,26 @@ class BackoffJob
{
}
+#[Backoff(backoff: 30)]
+class BackoffNamedScalarJob
+{
+}
+
#[Backoff([10, 20, 30])]
class BackoffArrayJob
{
}
+#[Backoff(backoff: [10, 20, 30])]
+class BackoffNamedArrayJob
+{
+}
+
+#[Backoff(10, 20, 30)]
+class BackoffVariadicJob
+{
+}
+
#[Connection('redis')]
class ConnectionJob
{