fix(handler): enforce immutability when OPcache is enabled, and fail loudly when it cannot be - #16
Draft
lisachenko wants to merge 4 commits into
Draft
fix(handler): enforce immutability when OPcache is enabled, and fail loudly when it cannot be#16lisachenko wants to merge 4 commits into
lisachenko wants to merge 4 commits into
Conversation
With OPcache active the "interface gets implemented" callback receives a different zend_class_entry than the one the engine uses at runtime: the create_object pointer lives in the class entry itself and survives the trip through shared memory, but the write_property/get_property_ptr_ptr/ unset_property handlers live in a separate object handlers table that z-engine keys by the class entry address. They were therefore installed on a structure no object ever reads, and every write outside a constructor silently succeeded - with no error and no warning. The property handlers are now installed lazily from the create_object handler, which does receive the runtime class entry, before the new object picks up its handlers table. This also covers child classes, each of which has its own class entry. install() no longer assumes the result either: it links a private throwaway class implementing ImmutableInterface and checks that a write to it really reaches the handler, throwing a RuntimeException that names the cause and the workaround when it does not. The probe records a flag instead of throwing, because the handler runs inside an FFI callback where an escaping exception becomes an uncatchable fatal error. The root cause is upstream, tracked as lisachenko/z-engine#238; this change works around it and turns any remaining failure into a loud one. Refs #15 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013foRd1XwLwqjUSkSWeWrMe
None of the functional tests carried an --INI-- section, so each child process inherited whatever the machine happened to have configured - which is why the OPcache regression could never be caught: php8.4 ships opcache.enable_cli=Off, php8.5 ships it On. Every test now pins ffi.enable, opcache.jit and opcache.enable_cli explicitly. Two new tests: one runs the direct-write scenario with OPcache enabled and asserts the LogicException is still raised (it is not, without the fix in the previous commit), the other links an immutable class before install() runs and asserts install() reports the missing enforcement instead of returning. Refs #15 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013foRd1XwLwqjUSkSWeWrMe
The .phpt files now pin opcache.enable_cli themselves and one of them needs it switched on, which requires the extension to be loaded in the first place. Refs #15 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013foRd1XwLwqjUSkSWeWrMe
Closes #15 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013foRd1XwLwqjUSkSWeWrMe
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.
The defect
With OPcache active (
opcache.enable_cli=1on CLI,opcache.enable=1otherwise) immutability was silently not enforced:$object->publicProperty = 42outside a constructor just succeeded. No error, no warning.Root cause, confirmed by instrumenting the class entry addresses:
create_objectis a field ofzend_class_entryitself, so it survives OPcache's round-trip through shared memory and keeps firing. Thewrite_property/get_property_ptr_ptr/unset_propertyhandlers do not live there — z-engine keeps them in a separatezend_object_handlerstable keyed by the address of the class entry (ReflectionClass::getObjectHandlers()).ImmutableHandler::install()installed them from theinterface_gets_implementedcallback, which under OPcache sees a class entry at a different address than the one every object is later created from, so the handlers landed on a table nothing ever reads.This is an upstream z-engine behaviour and is tracked as lisachenko/z-engine#238 — nothing here tries to fix the engine.
What this PR changes
1. Enforcement is actually restored under OPcache (
src/ImmutableHandler.php). Theinterface_gets_implementedcallback now installs onlycreate_object, and the property handlers are installed lazily from thatcreate_objecthandler — which does receive the runtime class entry — right beforeproceed()allocates the object and attaches its handlers table. Installation is keyed on the class entry address and happens once per class entry, so child classes (each with their own class entry) are covered too. This is a library-level workaround using only z-engine's public API (ReflectionClass::fromCData(),Core::addressOf(), the existingset*Handler()methods); no engine structs are touched.2. A loud failure mode — the empirical self-check the issue asked for, not ini sniffing.
install()now linksImmutable\EnforcementProbe, a private throwaway class implementingImmutableInterface, writes to one of its properties and checks that the write really reached the handler. If it did not,install()throws aRuntimeExceptionnaming the cause and the workaround instead of returning normally:The probe records a flag rather than throwing — the handler runs inside an FFI callback, where an escaping exception becomes an uncatchable
Fatal error: Throwing from FFI callbacks is not allowed, which notry/catchininstall()could ever see. TheRuntimeExceptionitself is raised from ordinary userland code ininstall()and is catchable.The self-check is deliberately empirical: an environment can defeat the handlers in more ways than an ini value can describe (OPcache preloading, for instance, links classes before
install()ever runs and no ini sniff would notice). No ini sniffing was needed as a fallback.3. Test ini settings are pinned, and OPcache is covered. No functional test carried an
--INI--section, so children inherited whatever the machine had — andphp8.4shipsopcache.enable_cli=Offwhilephp8.5ships itOn. That asymmetry is exactly why CI never caught this. Every.phptnow pinsffi.enable,opcache.jitandopcache.enable_cli, plus two new tests:testDirectPublicPropertySetThrowsAnExceptionWithOpcacheEnabled.phpt— the regression test, runs withopcache.enable_cli=1and asserts theLogicExceptionis still raised.testInstallThrowsWhenEnforcementIsNotActive.phpt— links an immutable class beforeinstall()runs (what OPcache preloading does to every preloaded class) and assertsinstall()reports the missing enforcement..github/workflows/ci.ymlnow loads theopcacheextension so the enabled configuration can actually be exercised.4. A README note under Pre-requisites and initialization describing the limitation, the self-check, and the two ways the handlers can be made unreachable.
Verification
PHPUnit could not be installed in this environment — network egress only reaches
lisachenko/*, socomposer installwithout--no-devfails. Dependencies were installed withcomposer install --no-devfollowed bycomposer dump-autoload --dev(for theImmutable\ => tests/rule), and the.phptsuite was run through a minimal runner replicating what PHPUnit'sPhptTestCasedoes: it writes each--FILE--body next to the.phptso relative includes resolve, applies that file's own--INI--on top ofdisplay_errors=1, compares--EXPECT--exactly and matches--EXPECTREGEX--unanchored.--INI--(ffi.enable=1,opcache.jit=off,opcache.enable_cliper test)-d ffi.enable=1 -d opcache.jit=off -d opcache.enable_cli=1LogicException: Immutable object could be modified only in constructor or static methods(before this PR:NO EXCEPTION, value=300)install(), bothopcache.enable_cli=0and=1RuntimeExceptionwith the message aboveAlso run on both minors:
find src tests -name '*.php' | xargs -n1 php -l(clean) andcomposer validate --strict --no-check-lock(valid).Not done, on purpose
try/catcharound the lazy handler installation in thecreate_objectpath. Swallowing a failure there would recreate exactly the silent no-op this PR exists to remove, andinstall()has already proven that code path works in the current environment before any user object is created.install()time; making it opt-out would put the silent mode back within reach.Closes #15
Generated by Claude Code