Skip to content

fix(handler): enforce immutability when OPcache is enabled, and fail loudly when it cannot be - #16

Draft
lisachenko wants to merge 4 commits into
masterfrom
claude/z-engine-stable-updates-00g5v4
Draft

fix(handler): enforce immutability when OPcache is enabled, and fail loudly when it cannot be#16
lisachenko wants to merge 4 commits into
masterfrom
claude/z-engine-stable-updates-00g5v4

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

The defect

With OPcache active (opcache.enable_cli=1 on CLI, opcache.enable=1 otherwise) immutability was silently not enforced: $object->publicProperty = 42 outside a constructor just succeeded. No error, no warning.

Root cause, confirmed by instrumenting the class entry addresses:

opcache.enable_cli=1
[interface_gets_implemented] Immutable\Stub\TestObject ce_addr=0x7f652cfecd70   <- shared memory
[create_object]              Immutable\Stub\TestObject ce_addr=0x55e3191946c8   <- runtime
[runtime lookup]             Immutable\Stub\TestObject ce_addr=0x55e3191946c8

opcache.enable_cli=0
[interface_gets_implemented] ce_addr=0x7f7c07a853b0
[create_object]              ce_addr=0x7f7c07a853b0
[runtime lookup]             ce_addr=0x7f7c07a853b0

create_object is a field of zend_class_entry itself, so it survives OPcache's round-trip through shared memory and keeps firing. The write_property / get_property_ptr_ptr / unset_property handlers do not live there — z-engine keeps them in a separate zend_object_handlers table keyed by the address of the class entry (ReflectionClass::getObjectHandlers()). ImmutableHandler::install() installed them from the interface_gets_implemented callback, 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). The interface_gets_implemented callback now installs only create_object, and the property handlers are installed lazily from that create_object handler — which does receive the runtime class entry — right before proceed() 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 existing set*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 links Immutable\EnforcementProbe, a private throwaway class implementing ImmutableInterface, writes to one of its properties and checks that the write really reached the handler. If it did not, install() throws a RuntimeException naming the cause and the workaround instead of returning normally:

Immutability can not be enforced in this environment: a write to a property of a class implementing Immutable\ImmutableInterface is not intercepted, so writes outside of a constructor would silently succeed instead of raising an error. Check that ImmutableHandler::install() runs before any class implementing Immutable\ImmutableInterface is linked - OPcache preloading links classes ahead of it and those are never seen by the handler. If that is already the case, the engine hooks are not taking effect at all (see lisachenko/z-engine#238): disable OPcache for this process with opcache.enable=0, or opcache.enable_cli=0 on CLI.

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 no try/catch in install() could ever see. The RuntimeException itself is raised from ordinary userland code in install() 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 — and php8.4 ships opcache.enable_cli=Off while php8.5 ships it On. That asymmetry is exactly why CI never caught this. Every .phpt now pins ffi.enable, opcache.jit and opcache.enable_cli, plus two new tests:

  • testDirectPublicPropertySetThrowsAnExceptionWithOpcacheEnabled.phpt — the regression test, runs with opcache.enable_cli=1 and asserts the LogicException is still raised.
  • testInstallThrowsWhenEnforcementIsNotActive.phpt — links an immutable class before install() runs (what OPcache preloading does to every preloaded class) and asserts install() reports the missing enforcement.

.github/workflows/ci.yml now loads the opcache extension 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/*, so composer install without --no-dev fails. Dependencies were installed with composer install --no-dev followed by composer dump-autoload --dev (for the Immutable\ => tests/ rule), and the .phpt suite was run through a minimal runner replicating what PHPUnit's PhptTestCase does: it writes each --FILE-- body next to the .phpt so relative includes resolve, applies that file's own --INI-- on top of display_errors=1, compares --EXPECT-- exactly and matches --EXPECTREGEX-- unanchored.

PHP z-engine configuration result
8.4.19 8.4.2 full suite, each test's own --INI-- (ffi.enable=1, opcache.jit=off, opcache.enable_cli per test) 10/10 pass
8.5.9 8.5.0 same 10/10 pass
8.4.19 8.4.2 new OPcache test against the pre-fix handler fails (write not intercepted) — confirms it is a real regression test
8.5.9 8.5.0 new OPcache test against the pre-fix handler fails — same
8.4.19 / 8.5.9 8.4.2 / 8.5.0 direct script, -d ffi.enable=1 -d opcache.jit=off -d opcache.enable_cli=1 LogicException: Immutable object could be modified only in constructor or static methods (before this PR: NO EXCEPTION, value=300)
8.4.19 / 8.5.9 8.4.2 / 8.5.0 direct script, probe class linked before install(), both opcache.enable_cli=0 and =1 RuntimeException with the message above

Also run on both minors: find src tests -name '*.php' | xargs -n1 php -l (clean) and composer validate --strict --no-check-lock (valid).

Not done, on purpose

  • No engine-level fix. The class-entry identity problem belongs to z-engine (#238); this PR works around it from the library and does not patch or reach into engine structures.
  • No try/catch around the lazy handler installation in the create_object path. Swallowing a failure there would recreate exactly the silent no-op this PR exists to remove, and install() has already proven that code path works in the current environment before any user object is created.
  • The self-check cannot be disabled. It costs one class link and one property write, once, at install() time; making it opt-out would put the silent mode back within reach.

Closes #15


Generated by Claude Code

lisachenko and others added 4 commits August 19, 2026 05:39
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Immutability is silently not enforced when opcache is enabled

1 participant