Repository navigation
feat(instrument): add the zengine weaving driver on top of lisachenko/z-engine - #765
Draft
lisachenko wants to merge 11 commits into
Draft
lisachenko wants to merge 11 commits into
lisachenko wants to merge 11 commits into
Conversation
…/z-engine
A second weaving driver, selected by the new `driver` kernel option
(`Go\Aop\WeavingDriver`: `stream`, the default, or `zengine`). The engine
driver weaves at runtime through lisachenko/z-engine (PHP FFI) in place of
the stream filter: ZEngineComposerLoader includes the original file natively
and ZEngineClassWeaver rewires the class right after the include - the body
of every advised method is swapped in place with a generated dispatcher
while the original body stays callable under the private
`<method>OriginalAlias` (`ReflectionMethod::redefine($donor, preserveAs:)`),
an inherited method gets an engine-published override reaching the original
through `parent::` (`ReflectionClass::addMethod()`). No source rewriting, no
proxy class, no include interception.
The dispatcher bodies are compiled in a donor class `Foo__AopDonor`
(DonorClassGenerator, extending ClassProxyGenerator): abstract, same
namespace, imports, parent and interfaces, only the advised methods; the
joinpoint names the woven class explicitly and `self`/`parent` in signatures
are spelled out (TypeGenerator::resolveScopeKeywords()), since the donor file
is compiled in another class. Donors are cached below `{cacheDir}/_zengine/`
and indexed in `_zengine.cache` (DonorCacheIndex) with the freshness rule
shared with the stream driver (CachePathManager::isFreshRecord()).
Method execution join points only: property access, introductions,
initialization join points, function interception and prebuilt caches stay
with the stream driver (UnsupportedJoinpointException on the first load of a
class matched by such an advisor, stream-only features refused by
normalizeOptions(), cache:warmup:aop and debug:weaving refuse the driver).
JitGuard: z-engine needs the opcache JIT off; on PHP 8.5 the tracing JIT
also miscompiles the framework (known PHP bug), so the guard switches it off
at runtime there and reports it, other versions get a configuration error.
The functional test base passes `opcache.jit=off` to every PHP subprocess on
PHP 8.5 for the same reason.
z-engine is an optional dependency (each branch targets one PHP minor):
phpstan/zengine-stubs.php declares the API for the analysis without the
package, the `zengine` PHPUnit group (`composer test:zengine`, both opcache
legs, compared with the stream driver on the same fixtures) is excluded
from the default run and self-skips without the engine, and
.github/workflows/zengine.yml installs the package on PHP 8.5.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #765 +/- ##
============================================
+ Coverage 97.81% 97.87% +0.05%
- Complexity 1841 2050 +209
============================================
Files 104 114 +10
Lines 4849 5404 +555
============================================
+ Hits 4743 5289 +546
- Misses 106 115 +9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…gine branch of each minor The zengine workflow becomes a matrix over the PHP minors the driver is verified on, each leg installing the lisachenko/z-engine branch that targets that minor (feature/redefine-preserve-alias-8.4 for 8.4, feature/redefine-preserve-alias for 8.5). docs/zengine-driver.md names the branch per minor and the functional test base's skip message no longer hardcodes one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
The stream driver maps a source outside the application root to itself and never weaves it; the engine driver's loader applied the same path filter, but the on-demand weave() entry point did not, and the donor index placed the donor of such a source below the donor directory by its absolute path, which is not a valid path on Windows (the drive colon). The weaver now skips such sources and the index refuses them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
6 tasks done
…overage job The driver had no line coverage: its functional tests run it in PHP subprocesses. New in-process tests (tests/Instrument/ZEngine/InProcess/, group zengine) boot z-engine inside the test runner and weave dedicated stub classes (tests/Stubs/ZEngine) for real: the boot report and the services, own methods swapped and inherited methods overridden, a refused join point kind remembered per class, on-demand weaving of a class declared before the kernel, the warm path applying a donor record without loading the aspects, sources outside the application root. The kernel is created without the per-process singleton and installed as the singleton only for the duration of a test, since woven code resolves its aspects through AspectKernel::getInstance(). The coverage job installs lisachenko/z-engine (the branch of PHP 8.4) with FFI enabled and the JIT off, and includes the zengine group. Unit tests for the cache warmer refusal and UnsupportedJoinpointException. JitGuard::hasTracingJitBug() replaces the PHP_VERSION_ID comparisons of the tests, which PHPStan 2.3 folds to a constant verdict on PHP 8.4. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
The in-process tests now exercise the paths the functional probe never reaches: the engine boot refusals of ZEngineDriver (missing package, an engine that cannot boot, the JIT guard) through injectable seams, the rewriter's refusals (a class the engine class table does not know, a donor without the dispatcher method), a pointcut failing while matching (an ordinary class fails its load, an aspect is skipped), the parser fallback to native reflection, an aspect matched by its own pointcut, the records of a previous request served without the aspects, and the skips of internal classes, enums and classes without a source file. A readonly class gets a readonly donor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
5 of 6 tasks
…is merged there lisachenko/z-engine#282 was merged into 8.5 and its feature branch deleted, so the PHP 8.5 leg and the install instructions point at dev-8.5. PHP 8.4 keeps pinning the feature branch until lisachenko/z-engine#284 lands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
Composer exposes a branch named like a version as 8.5.x-dev, not dev-8.5: the PHP 8.5 leg could not resolve the package after the previous commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…is merged there lisachenko/z-engine#284 was merged into 8.4, so the PHP 8.4 driver leg, the coverage job and the install instructions require 8.4.x-dev instead of the feature branch; both minors now run against the version branches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…roxy
Two gaps the demos exposed under the engine driver:
The fluent-interface demo guards the chain with `instanceof Go\Aop\Proxy`,
which a class woven in place did not implement: the aspect ran, the demo
still reported AOP as unavailable. MethodTableRewriter now adds the marker
interface to the rewired class through z-engine's ReflectionClass::addInterfaces(),
so `instanceof Proxy` (the demo, the Doctrine bridge) answers the same under
both drivers; an unadvised class stays unmarked.
Function interception was refused with the engine driver, although it needs
no engine mutation at all: the stream driver's function proxies are namespaced
functions shadowing the advised internal ones, and PHP resolves an unqualified
call to the namespaced function before the global one. ZEngineClassWeaver
now generates the same proxies per namespace (FunctionProxyGenerator, written
to {cacheDir}/_zengine/_functions/<Ns/Path>.php and recorded in the donor index
with the function names) and includes them right after the file declaring the
first loaded class of the namespace, before any code of it runs. A warm
request includes the recorded file without the aspects; functions already
declared in the process (another kernel of a test runner) are never included
twice; the global namespace is left alone, as with the stream driver.
INTERCEPT_FUNCTIONS is accepted by the kernel for the engine driver.
The demos select the driver through GO_AOP_DRIVER=zengine.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…ngine driver Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Proof of concept of a second weaving driver that keeps the whole existing stream-filter engine untouched and adds runtime weaving through lisachenko/z-engine (PHP FFI) as a parallel path, selected by the new
driverkernel option (Go\Aop\WeavingDriver:stream, the default, orzengine).With the engine driver classes load natively:
ZEngineComposerLoaderincludes the original file exactly as composer would, andZEngineClassWeaverrewires the class right after the include. The body of every advised method is swapped in place with a generated dispatcher while the original body stays callable under the private<method>OriginalAlias(ReflectionMethod::redefine($donorMethod, preserveAs: ...)); an inherited method gets an engine-published override reaching the original throughparent::(ReflectionClass::addMethod()). No source rewriting, no stream filter, no proxy class, no include interception. Advices, pointcuts, the advisor cache and the join-point API are shared with the stream driver; the functional tests run the same fixtures through both drivers and compare results, advice log and reflection.Main pieces:
Go\Aop\WeavingDriver, thedriveroption inAspectKernel(the transformer-onlyFeaturesrefused with the engine driver),ZEngineDriver::boot()(requirements check,JitGuard,Core::init(), services, loader).Go\Proxy\DonorClassGenerator(extendsClassProxyGenerator): the abstract donorFoo__AopDonornext to the woven class, same imports/parent/interfaces, only the advised methods with the usual dispatcher bodies. Because the donor is compiled in another class, the joinpoint names the woven class explicitly (createJoinpointClassReference()hook onClassProxyGenerator) andself/parentin signatures are spelled out (TypeGenerator::resolveScopeKeywords(),ParameterGenerator::withType(),MethodGenerator::get/setParameters()); the engine compares the donor signature with the replaced entry.DonorCacheIndex({cacheDir}/_zengine.cache, donors below{cacheDir}/_zengine/) with the freshness rule extracted intoCachePathManager::isFreshRecord(), shared with the stream driver's metadata. Sources outside the application root are never woven, as with the stream driver.MethodTableRewriter: the whole engine contract, through z-engine's public reflection API only. The rewired class is marked withGo\Aop\Proxy(ReflectionClass::addInterfaces()), soinstanceof Proxy(the fluent-interface demo, the Doctrine bridge) answers the same under both drivers.Features::INTERCEPT_FUNCTIONS) without any engine mutation: the stream driver's namespaced function proxies (FunctionProxyGenerator) are generated per namespace into{cacheDir}/_zengine/_functions/, recorded in the donor index, and included right after the file declaring the first loaded class of the namespace; PHP resolves an unqualified call to the namespaced function before the global one. Same semantics as the stream driver: qualified calls and the global namespace are never intercepted.JitGuard: z-engine needs the opcache JIT off; on PHP 8.5 the tracing JIT also miscompiles the framework (known PHP bug), so there the guard switches it off at runtime and reports it in the boot report; other versions get anInvalidConfigurationException. The functional test base now passesopcache.jit=offto every PHP subprocess on PHP 8.5 for the same reason (the suite failed under a php.ini with the tracing JIT on).ComposerLoaderDecorator: both drivers decorate composer's loader;AopComposerLoader::init()/findOriginalFile()unwrap either.UnsupportedJoinpointExceptionon the first load of a class matched by such an advisor;cache:warmup:aop/debug:weavingrefuse the driver.GO_AOP_DRIVER=zengine.PHP versions. Verified on PHP 8.4 (8.4.19) and PHP 8.5 (8.5.11); the engine API the driver needs (lisachenko/z-engine#284 for 8.4, lisachenko/z-engine#282 for 8.5) is merged into both version branches, and the public surface of the driver is the same on both.
.github/workflows/zengine.ymlruns the group on both minors against8.4.x-devand8.5.x-dev.Tests and coverage. The
zenginePHPUnit group (excluded from the default run,composer test:zengine, self-skipping where the engine cannot boot) has two layers:tests/Functional/ZEngine/runs the driver in PHP subprocesses (both opcache legs, debug and production, warm index reuse, stale source regeneration, on-demand weaving, function interception, refused join points, cache warmer refusal, JIT guard) and compares it with the stream driver on the same fixtures;tests/Instrument/ZEngine/InProcess/boots z-engine inside the test runner and weaves dedicated stubs for real, which is what gives the driver line coverage. The coverage job ofphpunit.ymlinstalls z-engine (8.4.x-dev) with FFI on and the JIT off and includes the group.Dependency handling: z-engine stays an optional dependency (each of its branches targets one PHP minor, so it cannot be in
require-devwithout breaking the other legs).phpstan/zengine-stubs.php(scanFiles) declares the API the driver uses for the analysis without the package (verified with the package hidden).Known deviations, documented in
docs/zengine-driver.md: reflection of an advised method reports the donor file as the file of its body; a private/final method whose body is a literal may have been inlined by the opcache optimizer before the swap; a call site that ran before the function proxies were declared keeps the global function it resolved.Verified:
composer checkgreen on PHP 8.5 (3155 tests, PHPStan level 10 with 2.3.1, PER-CS) and the default suite plus PHPStan green on PHP 8.4;composer test:zengineon both minors against the engine heads (27 tests each); the fluent-interface and function-interceptor demos underGO_AOP_DRIVER=zengine, cold and warm with opcache.Checklist
type(scope): summary)composer checkpasses🤖 Generated with Claude Code
https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2