Skip to content

Release 8.4.2

Choose a tag to compare

@lisachenko lisachenko released this 18 Aug 12:04
1481a13

⚡ Z-Engine 8.4.2 — "The engine speaks its own language"

126 commits. 154 files. +6,400 / −3,000 lines.
A patch number that undersells it: the library that hands you PHP's newest internals is now written in them — plus a pluggable allocator seam that opens Z-Engine to memory it doesn't own. 🚪

composer require lisachenko/z-engine:^8.4

🔢 PHP 8.4 only. 8.4.x stays byte-exact for PHP 8.4. Everything here also cascades upward to master for the 8.5 line.


🧬 The modernization sweep

Z-Engine exposes PHP 8.4's newest internals. It was still written like it was 2019. A sustained refactor pass fixed that — the library now uses the language it exposes:

  • 🔐 Asymmetric visibility (private(set)) guards the engine views — Core::$executor, Core::$compiler and friends are readable but no longer reassignable from outside
  • 🏷️ #[\Override] across the reflection and hook layers — the compiler now catches a renamed parent method, instead of a test suite catching it much later
  • 🎯 First-class callable syntax for trampoline installation and resolved closures
  • 🔢 Backed enums for the constant groups Z-Engine owns — CastType, PropertyPurpose, SendMode, DependencyType, VersionRelation, DescriptorSlot. Raw magic integers at hook boundaries are gone.
  • 🔎 PHP 8.4 array predicates replacing hand-rolled search loops
  • 🏗️ Named arguments reconstructing opcache cache headers
  • 🧱 Typed class constants, match over the opcode-node value lookup, modernized string / class-name / coalesce idioms
  • 🏭 Named constructors for exceptions throughout the Memory, Type, module and opcode-table layers — every failure explains itself instead of arriving as a generic message

Concrete cleanups underneath: hook classes sealed, the flag-toggle and visibility-setter dances extracted, engine-struct access routed through owning classes, and the hook layer typed with the generated struct stubs from 8.4.1. Bridged-iterator state now travels in a typed object, and every proceed() routes through getOriginalCallable().

🔤 One rename worth noting: StringEntry::copy() is now StringEntry::addReference(). It always was a refcount bump — now the name says so.


🧠 The allocator seam

The most forward-looking addition here, and the groundwork for fork-shared and coroutine-scoped memory.

Every persistent primitive — PersistentObjectFactory's object clones, StringEntry::persistent()'s interned blocks, a PersistentHashTable's struct — used to allocate from exactly one place: the malloc-backed FFI allocator. Now they allocate through an Allocator interface you can implement:

interface Allocator
{
    public const int DEFAULT_ALIGNMENT = 16;

    public function allocate(int $size, int $alignment = self::DEFAULT_ALIGNMENT): int;
    public function ownsAllocations(): bool;
    // ...
}

Three deliberate decisions make it usable from outside the framework:

  • 📮 It speaks in addresses, not CData. A public Z-Engine API never leaks engine handles — so an implementation living in your package can be written against nothing but integers, binding its own mmap/shm primitives with FFI::cdef and returning the address it computed.
  • 🛑 ownsAllocations() is part of the contract. Z-Engine must never free(3) a block inside somebody else's arena — destroy() refuses rather than corrupting it.
  • 🔄 Passing nothing changes nothing. The default EngineAllocator is the malloc-backed allocator Z-Engine has always used.

The target sinks are the ones the process heap can't serve: a fork-shared mmap arena, a shared-memory segment, a preallocated slab. Paired with the new external arData install API, that means persistent engine structures living in memory Z-Engine doesn't own. 🧵


🌳 A consumer-safe compiler surface

Installing an AST hook meant reaching for internals and hoping. Now there's a bracket for it:

$result = Core::$compiler->processInCompilationMode(true, function (): mixed {
    // compile-time-only work, with the flag restored no matter how this exits
});

Consumer-safe file-name access for AST hooks lands alongside it — and the object-store and class-table operations consumers actually reach for are now named public API rather than something to rediscover from the source each time. ObjectEntry encapsulates its own store registration. 📇

HashTable also grows bucket-key lookup and replacement, and the Type layer exposes property slots and uncounted payload writes on the owning classes. 🪣


🩹 Fixes

  • 🎛️ Opline operands are read off the znode_op union instead of cast through it — the old approach worked by luck of layout, not by contract
  • 🔥 EG(fake_scope) is restored even when the original handler throws — an exception mid-hook no longer strands the engine in a foreign scope
  • 🐌 ObjectEntry resolves the dynamic-properties table lazily instead of eagerly on every wrap
  • 🛡️ addInterfaces() guards the class-table lookup
  • 🗑️ Every pDestructor-disabled delete routed through HashTable
  • 🧮 AST child indexes reject negative values through the plain bounds guard
  • 🧵 The materialized alias is dropped before a registered clone is freed
  • 📛 detectModuleName() kept off the null path of preg_replace_callback

⚡ Plus real performance work: loop-invariant struct sizes hoisted out of engine walk/copy loops, the executing frame resolved straight from execute_data in OpCodeHook, instances minted via native reflection with redundant casts dropped, and the last sizeof(type(...)) call sites migrated to Core::sizeOfType().


🏗️ Quality, tests & CI

This is where a surprising amount of the 126 commits went — and it's why the refactor above could be trusted:

  • 🧪 The test suite is typed, and now fails on swallowed deprecations and notices instead of passing quietly through them
  • 📊 The performance group runs in CI, and the AST node wrappers finally have coverage
  • 🧹 One PHPStan configuration instead of several, with PHPUnit taught to it, dead code and broken phpdoc parked in the baseline resolved, and baseline entries pruned as the refactors freed them
  • 🔒 Hardened workflows, third-party actions pinned to version tags, static-analysis tooling cached, Docker layer caches keyed on the base image, and Dependabot now watching actions and Docker images as well as Composer
  • 📋 A security policy and a pull request template
  • 📜 A uniform license header across the whole linted corpus, root-level scripts included

⚠️ Unchanged: still experimental

Z-Engine operates on raw engine memory. Segfaults are a feature of the territory, not a bug in your code. Match the branch to your PHP minor, keep the JIT off for the debugger primitives, and develop against a debug build.


💙 Thank you

A 154-file refactor of a library built on byte-exact struct offsets, landed without breaking it. Thanks to Claude for making this one possible. 🙏


🔍 Full diff 8.4.1...8.4.2