Skip to content

feat: support PHP 8.4 and 8.5 in parallel from one codebase - #142

Closed
lisachenko wants to merge 21 commits into
8.4from
claude/php-8.4-generics-support-oawvzf
Closed

feat: support PHP 8.4 and 8.5 in parallel from one codebase#142
lisachenko wants to merge 21 commits into
8.4from
claude/php-8.4-generics-support-oawvzf

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

Summary

One z-engine install now runs on PHP 8.4 and 8.5 in parallel. The branch merges master (which carried the 8.5 support) into the 8.4 line and unifies the two: both generated definition sets are bundled (include/8.4/, include/8.5/), Core::init() selects the set of the running interpreter, and every hand-declared value that moved between minors became a PHP_VERSION_ID conditional. z-engine owns all version-dependent header complexity — consumers (e.g. userland-php-generics) never see it.

What changed

Version-dependent code unified (src/)

  • Boot guard widened to [80400, 80600); the refusal message now lists every supported minor.
  • Core::ZEND_ACC_USE_GUARDS is 0x800 on 8.4 / 0x40000000 on 8.5, expressed as a conditional constant.
  • NodeKind: 8.5 inserted AST_OP_ARRAY/AST_CAST_VOID (shifting later kinds), added AST_PIPE and removed AST_CLONE/AST_EXIT. Every shifted kind is a conditional; kinds absent on the running minor get a negative sentinel, and the name() reverse map indexes only real kinds.
  • PayloadRelocator: matches ZEND_AST_OP_ARRAY only on 8.5 (kind 66 is AST_ZNODE on 8.4), and walks zend_attribute.validation_error + the DECLARE_ATTRIBUTED_CONST opline pair only where the engine has them.
  • FunctionBodySwap/PendingBodySwap/ClassDelta: the 8.4 statics-defaults duplication for closure donors is restored and gated — on 8.4 a closure donor owns (and destroys) its defaults table so the entry must duplicate; since 8.5 the table is guarded by the body refcount alone and duplicating would leak. The ownership rules are documented on unshareStaticVariables().

Tests

  • The 8.5-only file-cache shapes (attributed constant, const closure) use 8.5-only syntax, so they moved out of the shared answer.php fixture into answer-php85.php with a new Php85SerializerShapesTest in group opcache-php85, asserted skip-free via composer test:opcache85 on 8.5. The opcache group stays skip-free on every minor.

Tooling / CI / docs

  • composer.json: php: ~8.4.0 || ~8.5.0.
  • tools/generator/generate.php targets both minors by default, so composer gen-headers and the header-drift job cover the whole include/ tree; symbols.php regains the PHP_VERSION_ID gate around zend_ast_op_array (absent from 8.4 headers).
  • CI runs tests, PHPStan and the debug-build internal/opcache groups on a {8.4, 8.5} matrix.
  • README support matrix, AGENTS.md (version matching + branch model), CONTRIBUTING and the bug-report template describe the parallel model. 8.4 and master now carry the same two-minor support; master remains where the next minor lands first.

Verification (all local, in containers matching CI)

Check PHP 8.4.24 PHP 8.5.9
Default suite (release build) ✅ 408 tests ✅ 409 tests
--group internal (debug build, isolated) ✅ 142 tests
--group opcache --fail-on-skipped ✅ 26 tests ✅ 26 tests
--group opcache-php85 --fail-on-skipped n/a (version-gated) ✅ 2 tests
Worker-loop soak ✅ +32 B ✅ +32 B
composer gen-headers drift ✅ byte-identical ✅ byte-identical
PHPStan level max / cs:check

One pre-existing issue surfaced while testing (not introduced here, reproduces on pristine 8.4): running the full default suite on a debug 8.4.24 build trips a zend_hash consistency assertion in ClassDeltaTest::testRemovedOverrideRestoresInheritedMethod. CI never runs that combination (the default suite runs on release builds; debug jobs run only the internal/opcache groups, which are green) — left out of scope.

Companion PR

lisachenko/userland-php-generics has a matching PR that widens to php: ^8.4 and lisachenko/z-engine: 8.4.x-dev || 8.5.x-dev. Its 8.5 CI legs need this PR merged (and the Packagist mirror refreshed) before they can resolve.


Generated by Claude Code

claude and others added 21 commits August 6, 2026 14:11
PHP 8.5 constant expressions may embed a compiled static closure, which the
compiler represents as a zend_ast_op_array node carrying a zend_op_array
pointer instead of the child pointers the generic AST shape implies. The
OpCache payload relocator has to dereference that node, so the type joins both
the emitted-types list and the probed layout ground truth.

The rest of the generator needed no change for 8.5: every sliceStructs()
regex still matches (zend_closure, accel_time_t, zend_early_binding,
zend_persistent_script, zend_file_cache_metainfo), the clang include list is
unchanged, and FFI validates all 40 layouts against the C compiler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
Generated by tools/generator against PHP 8.5.9 NTS x64 (release) from the
matching php-src tag, ZEND_MODULE_API_NO 20250925.

Layout changes carried by these artifacts: zend_attribute gains
validation_error (delayed attribute target validation), zend_object_handlers
gains clone_obj_with (the `clone with` rework), zend_constant gains filename
and attributes, and zend_executor_globals gains the fatal-error backtrace
fields. The opcache structs (zend_persistent_script, zend_early_binding,
zend_file_cache_metainfo) and zend_closure are byte-identical to 8.4.

include/8.4 stays in the tree untouched: the 8.4 maintenance branch merges up
into master, and removing it here would turn every merge-up into a
modify/delete conflict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
ZEND_ACC_USE_GUARDS moved from 0x800 to 1 << 30 (0x40000000): 0x800 now means
only ZEND_ACC_DEPRECATED, so the stale value would have silently mis-read
every class with magic property accessors.

OpCode gains DECLARE_ATTRIBUTED_CONST (210), which PHP 8.5 emits instead of
DECLARE_CONST when a global const carries attributes. No opcode was
renumbered.

NodeKind is regenerated from the 8.5 enum: AST_OP_ARRAY, AST_CAST_VOID and
AST_PIPE are new, AST_CLONE and AST_EXIT are gone (both constructs now compile
to a ZEND_AST_CALL on the construct name), and 32 kinds shift as a result.
Neither removed kind was referenced outside the table.

LiveRange and ArgumentEntry were verified against Zend/zend_compile.h by hand -
no engine export exists for those macros - and are unchanged in 8.5.

OpCache: three file-cache serializer changes needed a matching port. Attributes
carry the new validation_error string; constant-expression ASTs may hold a
ZEND_AST_OP_ARRAY node (a compiled static closure) whose op_array pointer must
be relocated and walked, or a ZEND_AST_CALLABLE_CONVERT node that only touches
execution-only ZEND_MAP_PTR state; and an attributed global constant hangs its
attribute table off a ZEND_OP_DATA operand as an IS_PTR literal, which the
ordinary literal walk skips. Note that the operand is resolved as a literal
index rather than through the engine's RT_CONSTANT() byte-offset math: opcache
stores IS_CONST operands as indexes in the file cache and only converts them on
load, and this port keeps every opline in its serialized form.

The binary format itself is unchanged in 8.5 - same metainfo, same adler32
checksum, same system_id stamp, same bin path layout - so CacheMetaInfo,
BinaryCacheFile and SystemId needed no change.

The opcache fixture now compiles both new node shapes into the cached binary,
so the relocator walks them on every round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
master now builds against PHP 8.5; the 8.4 line lives on its own maintenance
branch. Core's boot guard, the composer platform requirement, the CI matrix,
both Dockerfiles and the PHPStan php version move together, since running
z-engine against a PHP minor it was not generated for is memory corruption
rather than a degraded mode.

The generator keeps 8.4 in its default target list even though master targets
8.5: the 8.4 branch merges up into master, and dropping include/8.4 from the
regenerated set would turn every merge-up into a modify/delete conflict.

PHPStan: the single baseline entry that spelled out ZEND_ACC_USE_GUARDS' old
numeric value is updated in place. A full baseline regeneration was rejected -
it rewrites ~540 lines on the untouched 8.4 master too, so the churn is
pre-existing drift and unrelated to this change. Verified that regenerating on
8.4 master and on this branch differ in exactly that one line, i.e. these edits
introduce no new static-analysis errors.

PHP 8.5 deprecates the report_memleaks directive; the test-suite calls that use
it to suppress leak reports for immortal-by-design allocations are silenced,
as it remains the only switch for that behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
master is now the PHP 8.5 line; 8.4 keeps its own maintenance branch and stays
supported. The bug-report template asks for an 8.5 `php -v` line and spells out
which branch each version lives on, so reports land against the right one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
… 8.5

The dual-target gen-headers run broke on the 8.4 image: zend_ast_op_array
only exists since PHP 8.5, so gate the manifest entries on the running
PHP version (symbols.php executes inside the target container).

The debug image build required an opcache.so, but since PHP 8.5 opcache
is linked statically - emit the zend_extension line only when the shared
extension exists and rely on the existing extension_loaded sanity check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
Review feedback on #134: include/8.4 artifacts are maintained on the 8.4
branch and flow into master via the cascade merge-up, so master's
gen-headers run (and the header-drift job) only needs the 8.5 target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
Review feedback on #134: master's generator only ever runs against
PHP 8.5 now, so the manifest can list the type unconditionally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
The debug-build leak gate caught redefine-churn losing three blocks per cycle
on PHP 8.5 (a zend_array, its arData and one key string - a whole
static-variables table), leak-free on 8.4.

PHP 8.5 changed who owns a closure's static variables. zend_create_closure_ex()
used to duplicate the prototype defaults into the closure's OWN
op_array.static_variables field; it now duplicates them into the ZEND_MAP_PTR
slot only and leaves the struct field aliasing the prototype's table, whose
single destroy is tied to the body refcount. The two releases that used to
compensate went away with it: zend_closure_free_storage() no longer nulls
static_variables, and destroy_op_array() no longer unconditionally frees the
static_variables of a dying op_array's closure prototypes.

The swap assumed the old model and minted the entry an independent duplicate,
overwriting op_array.static_variables. On 8.5 that field is the only handle
through which the entry's body reference could still reach the prototype's
table, so once it was replaced nothing freed the original: the eval'd op_array
had already returned early on the shared refcount, and the entry's own
destroy_op_array went on to free the duplicate instead.

No donor kind owns that table on 8.5, so the duplication is not merely
unnecessary but actively wrong - the body refcount already guards it and the
last holder frees it exactly once. Dropping it takes the whole minted-defaults
bookkeeping with it (the registry, the rollback restore records and the
duplicateStatics parameter, whose only `true` caller was redefine()). The live
per-entry table is still unshared by dropping the ZEND_MAP_PTR slot, so
statics keep materializing lazily from the defaults on first ZEND_BIND_STATIC.

Verified against a locally built PHP 8.5.9 --enable-debug: all 25 leak
scenarios report no leaks (redefine-churn was 300), and the internal group runs
138 tests with only 2 skips instead of 27, since the leak gates actually
execute on a debug build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
merge: cascade 8.4 into master
Updates the requirements on [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit) to permit the latest version.
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/13.2.6/ChangeLog-13.2.md)
- [Commits](sebastianbergmann/phpunit@12.2.0...13.2.6)

---
updated-dependencies:
- dependency-name: phpunit/phpunit
  dependency-version: 13.2.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
…hpunit-tw-12.2or-tw-13.0

chore(deps-dev): update phpunit/phpunit requirement from ^12.2 to ^12.2 || ^13.0
merge: cascade 8.4 into master
… PHP 8.5

testGetThis swaps the running frame's $this to a stdClass to demonstrate the
capability, but left the foreign object in the frame's This slot. At frame
cleanup the engine releases whatever sits there while the original object is
still referenced elsewhere, and PHP 8.5's stricter allocator reports the
resulting imbalance as 'zend_mm_heap corrupted' + SIGABRT (PHP 8.4 tolerated
it silently). The fix restores the original $this before the frame unwinds:
swapping is a real capability, not cleaning up after it in a *live* frame was
the bug. Pre-existing on master; surfaced by the 8.4->master cascade running
its suite on 8.5.9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1Cp8nVraSrjYe3NvKP7af
merge: cascade 8.4 into master (regenerated 8.5 headers + 8.5 heap-safety fix)
Engine definitions for both minors are bundled (include/8.4, include/8.5)
and Core::init() already selects the set of the running interpreter; this
widens the boot guard to [8.4, 8.6) and turns every hand-declared value
that moved between minors into a PHP_VERSION_ID conditional:

- Core::ZEND_ACC_USE_GUARDS moved from 0x800 to 0x40000000 in 8.5
- NodeKind AST_* kinds shifted when 8.5 inserted AST_OP_ARRAY and
  AST_CAST_VOID, added AST_PIPE and removed AST_CLONE/AST_EXIT; kinds
  absent on the running minor carry a negative sentinel and the name()
  reverse map only indexes real (non-negative, public) kinds
- PayloadRelocator matches ZEND_AST_OP_ARRAY only on 8.5 (66 is
  AST_ZNODE on 8.4) and walks zend_attribute.validation_error and the
  DECLARE_ATTRIBUTED_CONST opline pair only where they exist
- FunctionBodySwap restores the 8.4 statics-defaults duplication for
  closure donors (which own their table and destroy it on death there)
  while keeping the 8.5 rule of never duplicating (the table is guarded
  by the body refcount alone since zend_create_closure_ex changed);
  the ownership rules are documented on unshareStaticVariables()

The 8.5-only file-cache shapes (attributed constants, const closures)
use 8.5-only syntax, so they move out of the shared answer.php fixture
into answer-php85.php with their own opcache-php85 test group, keeping
the opcache group skip-free on every supported minor.

Verified: full suite green on release 8.4.24 and 8.5.9, internal and
opcache groups green on a debug 8.4 build, both header sets regenerate
byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184AgTjKVX4S6qmvRsdM6dt
- composer.json widens php to ~8.4.0 || ~8.5.0 and gains the
  test:opcache85 script for the version-gated file-cache shape tests
- generate.php defaults to BOTH minors, so a symbol change regenerates
  every bundled definition set and header-drift checks the whole tree
- symbols.php restores the PHP_VERSION_ID gate around zend_ast_op_array
  (the struct does not exist in 8.4 headers)
- CI runs tests, PHPStan and the debug internal/opcache groups on a
  {8.4, 8.5} matrix; the opcache-php85 group is asserted skip-free on
  8.5 only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184AgTjKVX4S6qmvRsdM6dt
README support matrix, AGENTS.md version-matching and branch-model
sections, CONTRIBUTING and the bug-report template now describe one
line supporting both minors, with z-engine owning all header/version
complexity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184AgTjKVX4S6qmvRsdM6dt
ConstantAttributesRule refuses to check attributed constants unless the
RUNTIME is PHP 8.5, so the 8.4 matrix leg failed on answer-php85.php.
The file is opcache fixture payload, not library code - excluded like
answer.php. Also documents why phpVersion stays pinned to the newest
supported minor instead of the [8.4, 8.5] range: the range folds the
out-of-range boot guard into an always-false branch at level max.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184AgTjKVX4S6qmvRsdM6dt
@lisachenko

Copy link
Copy Markdown
Owner Author

Please stop here, only relevant consumer library should support several versions in parallel, not the z-engine itself. Technically, master already supports parallel binaries, so not merge back here php8.5 changes

Copy link
Copy Markdown
Owner Author

Understood — closing this, z-engine keeps its one-minor-per-branch model.

The consumer-side goal is already achieved with no z-engine change needed: userland-php-generics#13 takes lisachenko/z-engine: "8.4.x-dev || 8.5.x-dev", so composer resolves the 8.4 branch on a PHP 8.4 platform and master (through its 8.5.x-dev branch alias) on PHP 8.5. Its full CI matrix — tests, opcache, debug internal group, PHPStan on both 8.4 and 8.5, plus an experimental 8.6-nightly analysis job — is green with that resolution.


Generated by Claude Code

@lisachenko lisachenko closed this Aug 7, 2026
@lisachenko
lisachenko deleted the claude/php-8.4-generics-support-oawvzf branch August 7, 2026 21:53
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.

2 participants