Repository navigation
feat(driver): pluggable weaving drivers: stream filter, stream wrapper and ffi (z-engine) - #765
lisachenko wants to merge 22 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 98.13% 98.19% +0.05%
- Complexity 1891 2021 +130
============================================
Files 113 118 +5
Lines 5037 5203 +166
============================================
+ Hits 4943 5109 +166
Misses 94 94
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ 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
…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
…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
lisachenko
left a comment
There was a problem hiding this comment.
Too much changes in the framework + scattered implementation. I propose to refactor the implementation via low coupling - to define the driver as a plugin, which is stored in isolated package + namespace. And has minimum tangling.
You can restruct existing code to organize drivers first and to define interfaces and way how to use different weaving drivers.
…engine driver into packages/ffi-driver The `driver` kernel option names the class of a `Go\Driver\WeavingDriver`: the kernel instantiates it, registers it in the container and boots it once; a driver running the transformer chain implements `Go\Driver\SourceTransformingDriver`, for which alone the kernel registers the transformer services and the cache warmer builds a cache. The stream filter driver stays the built-in default (`Go\Driver\StreamFilter\StreamFilterDriver`), `SourceTransformingLoader::transformSource()` is the shared entry point of the source-transforming drivers. The z-engine driver becomes the ffi driver of its own package (`packages/ffi-driver`, `Go\Driver\Ffi`): the donor is a trait named `<Short>OriginalTrait` (the suffix of the trait codegen; `self::class` and `parent::` then bind at run time, so the joinpoint reference hook leaves the core), functions are proxied from the registered pointcuts through `AdvisorIndex` (no feature flag), the JIT guard switches an active JIT off on any PHP version, exceptions use named constructors of the package's own classes. The package has its own tests, composer.json and phpstan config; the root `composer test` excludes the `ffi-driver` testsuite, `composer test:ffi` / `composer analyze:ffi` run it. CI: the path-triggered `ffi-driver.yml` installs `lisachenko/z-engine` plainly (no branches, no vcs repositories), the coverage job no longer touches z-engine. Docs per driver live in `docs/driver/`, the option is documented as a class name, the demo is back to master's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
`goaop/stream-wrapper-driver` (`Go\Driver\StreamWrapper\StreamWrapperDriver`, a
SourceTransformingDriver) takes the place of the `file://` stream wrapper
(`stream_wrapper_unregister('file')` + `stream_wrapper_register('file', AopStreamWrapper::class)`):
every file included from within the weaving scope is served through
`SourceTransformingLoader::transformSource()`, every other file operation is delegated to the
native wrapper. Same join points, transformer chain, caches and warmup as the stream filter
driver; includes are intercepted by nature (`Features::INTERCEPT_INCLUDES` is refused as implied)
and opcache caches the woven sources under their original path, which it never does for a
`php://filter` include. The functional tests run the core class weaving and magic constant
suites through the wrapper and compare the fixture probe with the stream filter driver.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
Boot of the ffi driver: the JIT guard runs before anything of z-engine is autoloaded, the installed engine is probed without autoloading, and an engine release without the method-table API (redefine() with preserveAs, addMethod(), fromClassTable()) is refused at boot instead of failing at the first weave; the functional tests skip on such an engine. ClassWeaver keys its results by the declared class name, never lets a file outside the application root decide for its namespace, and takes no "no functions" decision while no advisor is registered yet. The class-table refusal gets a named constructor of its own. Core: an init()-level test pins the driver boot (once, registered in the container, before configureAop(), transformer chain for source-transforming drivers only); the matrix test job excludes the ffi suite like the coverage job; the init-flow notes of src/Instrument/AGENTS.md follow transformSource() and isFreshRecord(); codecov flags are scoped to their paths; the ffi workflow also reacts to the fixture project it consumes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…erfaces of master Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
|
Status after the review round (head 12013dd, master #771 merged in): every check is green or running except the two ffi driver (PHP 8.4 / 8.5) jobs, which fail in That is not this PR's code: the plain Generated by Claude Code |
…on messages Ownership metadata, symlink stats, every operation of an unopened instance, the stat of a deleted served file and an include through an unregistered instance are exercised; the three guards PHP cannot trigger (a refused registration, an unreadable native handle, a php://memory that does not open) are marked as not coverable. 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
__FILE__ and the opened include-path entry are compared through realpath() (backslashes on Windows), the exact mode after chmod() is asserted on POSIX only, and the ownership/symlink test skips where chown()/chgrp() and unprivileged symlinks do not exist. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…olate the refused registration The transformation record is keyed by the resolved path of the included file, which Windows spells with backslashes; the unreachable registration failure moves into a method of its own that coverage ignores. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
…ealpath() as well Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2
What and why
Weaving becomes a pluggable driver of the kernel: the
driveroption names the class that decides how advised classes get their advices woven in, everything else (aspects, pointcuts, advisor cache, join-point runtime) is shared. Three drivers, two of them packages of their own underpackages/:Go\Driver\StreamFilter\StreamFilterDriver(default)php://filterstream filter behind composer's loader (the existing engine, untouched)Go\Driver\StreamWrapper\StreamWrapperDriverpackages/stream-wrapper-driver(goaop/stream-wrapper-driver)stream_wrapper_unregister('file')+stream_wrapper_register('file', AopStreamWrapper::class): every include within the weaving scope is served transformed, includes are intercepted by nature, opcache caches the woven sources under their original pathGo\Driver\Ffi\FfiDriver(experimental)packages/ffi-driver(goaop/ffi-driver)<method>OriginalAliasCore (diff against master:
src/Driver/, 13 files)Go\Driver\WeavingDriver::boot(AspectKernel, AspectContainer):AspectKernel::init()instantiates the configured class, registers it in the container and boots it once (after the framework services, beforeconfigureAop()). An unknown class or a class that is no driver is refused bynormalizeOptions().Go\Driver\SourceTransformingDriver(marker): only for such a driver the kernel registers the transformer chain (registerTransformerServices()) and the cache warmer builds a cache ahead of time; any other driver is refused bycache:warmup:aop/debug:weaving.SourceTransformingLoader::transformSource($uri, $source, $stream): the cache-or-transform logic extracted fromfilter(), shared by the stream filter and the stream wrapper.CachePathManager::isFreshRecord()/writeIndexFile()(the one freshness rule and the portable index format, used by the ffi index),ComposerLoaderDecorator(every driver wrapping composer's loader), generic code-gen helpers (TypeGenerator::resolveScopeKeywords(),ParameterGenerator::withType(),MethodGenerator::get/setParameters()), exception constructors following master's convention.ffi driver package
FfiDriver::boot()refusesFeatures::INTERCEPT_INCLUDES, requires z-engine, runs the JIT guard (an active JIT is switched off at runtime and reported), boots the engine and wraps composer's loader withFfiComposerLoader;ClassWeavermatches advices on the parsed source and generates the donor traitNs\FooOriginalTrait(DonorTraitGenerator, the suffix and structure of the trait codegen: in a traitself::classandparent::bind at run time to the woven class, only theself/parentkeywords of signatures are spelled out because the engine compares signatures against the declaring type);MethodTableRewriterswaps the bodies (redefine(..., preserveAs:),addMethod()) and marks the class withGo\Aop\Proxy;DonorCacheIndexkeeps{cacheDir}/_ffi.cache. Function pointcuts are served by the stream driver's namespaced function proxies, enabled from the registered pointcuts (AdvisorIndex), no flag. Everything but method execution and functions is refused withUnsupportedJoinpointException. Exceptions:EngineConfigurationException(configuration),FfiWeavingException/UnsupportedJoinpointException(weave time).Tests and CI
framework,stream-wrapper-driver(part ofcomposer test) andffi-driver(composer test:ffi, excluded fromcomposer test, self-skips without the engine;composer analyze:ffianalyses the package with the real engine). The ffi package has unit tests, in-process tests weaving its stubs through z-engine inside the runner, and functional tests comparing the fixture probetests/Fixtures/project/bin/driver-weaving.phpwith the stream filter driver; the wrapper package runs the core class weaving and magic constant suites through the wrapper and compares the same probe..github/workflows/ffi-driver.ymlis triggered bypackages/ffi-driver/**,src/Driver/**, the workflow andcomposer.jsononly, on PHP 8.4 and 8.5, with a plaincomposer require lisachenko/z-engine; the coverage job no longer installs z-engine (package coverage comes from the ffi workflow under theffi-drivercodecov flag).redefine(preserveAs:)/addMethod()API; the 8.4 and 8.5 heads must be tagged8.4.3/8.5.1for the ffi workflow (and the package constraint~8.4.3 || ~8.5.1) to resolve.Docs:
docs/driver/README.md(the concept, the interface, how to write a driver),stream-filter.md,stream-wrapper.md,ffi.md; AGENTS.md per package.Verified locally on PHP 8.5.11 and 8.4.19:
composer check(3284 tests, PHPStan level 10, PER-CS) andcomposer test:ffi(52 tests) green on both.Checklist
type(scope): summary)composer checkpasses🤖 Generated with Claude Code
https://claude.ai/code/session_01VLQcsvgdQUzwBFsoKUAcG2