From 11a9193e4500d7616683c1c66c69adc4937d779d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 11:50:57 +0000 Subject: [PATCH 1/3] feat: name the public consumer API - Core::isUsable(), Core::sizeOfType(), ClassSpecializer::evict() Dependant packages were reaching behind the API line for operations that had no named entry point: probing Core::$executor to ask "is the engine booted", deleting class-table buckets through the @internal HashTable::delete(), and pairing Core::sizeof(Core::type(...)) so a raw FFI\CType crossed the boundary. - ClassSpecializer::evict(): the destroying counterpart of specialize(). Removes the class-table bucket so destroy_zend_class() dismantles the entry now, while op_array-refcounted bodies shared with the source stay alive. Refuses internal and shared-memory (immutable/preloaded) entries. The explicit-teardown test now exercises it. - Core::sizeOfType(): the named form of the sizeof(type(...)) pair; Core::type() is @internal so no raw CType crosses the API line anymore. - Core::isUsable(): non-throwing projection of the boot guard, for consumers and test bootstraps deciding whether engine paths can run here at all - hand-rolled ffi.enable checks get the supported 'preload' mode wrong. - AGENTS.md: a 'Consuming z-engine from another package' section drawing the line those leaks crossed because it was never written down. No ReflectionClass::evict() mirror on purpose: an instance method destroying the entry its own wrapper points at invites use-after-free on $this. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019TGQqR7ByjHrVSYKVHPrkJ --- AGENTS.md | 20 +++++++++ docs/class-specialization.md | 10 +++++ src/Core.php | 40 +++++++++++++++++ src/Reflection/ClassSpecializer.php | 52 +++++++++++++++++++++++ tests/Reflection/ClassSpecializerTest.php | 20 +++++++-- 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index caecb0c..af6614b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,26 @@ inside the module; consumers see plain PHP values and framework wrapper objects it or convert it. This is what keeps the FFI blast radius confined to code that is audited for it. +## Consuming z-engine from another package + +Dependant packages (userland-php-generics is the reference consumer) talk to z-engine through +its documented public API and nothing else: + +- **Lifecycle**: `Core::init()`, `Core::preload()`, `Core::isInitialized()` (never probe + `isset(Core::$executor)` — the wrappers are assigned mid-boot, before the layout checks + pass, so the probe reports a half-booted bridge as ready), and `Core::isUsable()` for + "should the engine paths run in this environment at all" (never re-derive that from + `ini_get('ffi.enable')` — a boolean filter rejects the supported `preload` mode). +- **Services and wrappers**: `ClassSpecializer` (including `evict()`), `HotSwap`, + `PersistentHeap`, the `Reflection\*` and `Type\*` wrapper objects, and the substitution + value objects (`TypeSubstitutionMap`, `SlotSubstitutionMap`, `TypeSlot`). + +Off-limits to dependants, without exception: the engine-global wrappers +`Core::$executor` / `Core::$compiler` / `Core::$modules`, every method marked `@internal`, +and anything returning a raw `FFI\CData`/`FFI\CType`. If a dependant needs an operation that +only exists behind that line — as happened with class-table eviction and struct sizes — the +fix is a named public API here, not a reach-through there. + ## Engine structs are owned by their reflection/type class, never poked from call sites This applies to EVERY class: if a class is responsible for a structure, then all external diff --git a/docs/class-specialization.md b/docs/class-specialization.md index 0bfeb1b..490d619 100644 --- a/docs/class-specialization.md +++ b/docs/class-specialization.md @@ -27,8 +27,18 @@ $specialized = (new ClassSpecializer())->specialize( ); $instance = $specialized->newInstance(); // or: new \App\Specialized\SomeTemplateInt() + +// The counterpart: destroy a runtime-registered class now, instead of at request shutdown +(new ClassSpecializer())->evict('App\Specialized\SomeTemplateInt'); // true, or false if unknown ``` +`evict()` deletes the class-table bucket, which runs the engine's own +`destroy_zend_class()` over the entry immediately while everything shared with the source +(method bodies, via the op_array refcount) stays alive — the memory-ownership contract +below is exercised at that moment rather than at request end. It refuses internal classes +and shared-memory (immutable/preloaded) entries with a `ClassSpecializationException`; +eviction is for runtime-registered copies, which are always plain userland classes. + A *placeholder* is a class-like type name used in the template declaration (for example `public TPlaceholder $value;` where `TPlaceholder` is never defined as a real class). `TypeSubstitutionMap` maps placeholder names to concrete types; matching is diff --git a/src/Core.php b/src/Core.php index 3648ed1..b063dd7 100644 --- a/src/Core.php +++ b/src/Core.php @@ -366,6 +366,29 @@ public static function isInitialized(): bool return self::$initialized; } + /** + * Whether this environment can boot z-engine at all: ext-ffi loaded, ffi.enable set to a + * working value (`1` or `preload` - the latter needs an opcache.preload script calling + * Core::preload()), a supported PHP minor, and generated engine definitions for this + * platform. + * + * The non-throwing projection of the boot guard: init() explains a refusal, this one + * reports it. Dependants and test bootstraps that need "should the engine paths run + * here?" ask this instead of re-deriving the rule from ini_get('ffi.enable') - which is + * spelled several different ways and means different things per SAPI, so a hand-rolled + * check is wrong somewhere (a boolean filter rejects the supported `preload` mode). + */ + public static function isUsable(): bool + { + try { + self::assertSupportedEnvironment(); + } catch (RuntimeException) { + return false; + } + + return true; + } + /** * Refuses to boot on any PHP build this branch has no verified structure definitions for. */ @@ -615,6 +638,20 @@ public static function sizeof($cType): int return FFI::sizeof($cType); } + /** + * Returns the size in bytes of an engine type, looked up by name + * + * The named form of the sizeof(type(...)) pair, and the one consumers should use: it + * answers "how big is a zend_op_array here?" without a raw FFI\CType ever crossing the + * API boundary. + * + * @param string $type Name of the engine type (eg "zend_class_entry") + */ + public static function sizeOfType(string $type): int + { + return FFI::sizeof(self::$engine->type($type)); + } + /** * Returns the size of given type */ @@ -916,6 +953,9 @@ public static function free(CData $variable): void * Returns a CType definition for engine by type name * * @param string $type Name of the type + * + * @internal returns a raw FFI\CType, which must not cross the API boundary - consumers + * wanting a size use sizeOfType() */ public static function type(string $type): CType { diff --git a/src/Reflection/ClassSpecializer.php b/src/Reflection/ClassSpecializer.php index 03f8d38..8e44c1e 100644 --- a/src/Reflection/ClassSpecializer.php +++ b/src/Reflection/ClassSpecializer.php @@ -128,6 +128,58 @@ class_exists($sourceClassName); return ReflectionClass::fromCData($newEntry); } + /** + * Removes a runtime class from the engine class table, destroying its class entry NOW + * + * The counterpart of specialize(): deleting the class-table bucket runs the engine's own + * destroy_zend_class() over the entry immediately - tables, own property infos and + * constants, owned names - instead of at request shutdown, while everything the class + * shares with its source (method bodies through the op_array refcount) stays alive. + * Destroying a specialization while its template is still in use is exactly the moment + * the memory-ownership contract of the copy model is testable; see + * docs/class-specialization.md. + * + * Only classes the engine tears down through the request allocator are evictable. An + * internal class and an opcache-shared (ZEND_ACC_IMMUTABLE) or preloaded entry live in + * memory this process must never dismantle, so they are refused - eviction is for + * runtime-registered copies, which are always plain userland classes. + * + * @param string $className Name of the registered class to destroy + * + * @return bool false when no class of that name is registered, true after eviction + * + * @throws ClassSpecializationException When the registered class is not evictable + */ + public function evict(string $className): bool + { + $lowerName = strtolower($className); + $classValue = Core::$executor->classTable->find($lowerName); + if ($classValue === null) { + return false; + } + + $classEntry = $classValue->getRawClass(); + $sourceKind = $classEntry->type; + assert(is_string($sourceKind)); + if (ord($sourceKind) !== Core::ZEND_USER_CLASS) { + throw new ClassSpecializationException( + "Cannot evict internal class {$className}: only userland classes are supported", + ); + } + $classFlags = $classEntry->ce_flags; + assert(is_int($classFlags)); + if (($classFlags & (Core::ZEND_ACC_IMMUTABLE | Core::engineConstant('ZEND_ACC_PRELOADED'))) !== 0) { + throw new ClassSpecializationException( + "Cannot evict {$className}: its class entry lives in shared memory, which this " + . 'process must never dismantle', + ); + } + + Core::$executor->classTable->delete($lowerName); + + return true; + } + /** * Copies an opcache-shared (ZEND_ACC_IMMUTABLE) class entry out of shared memory into a * writable per-process copy published under the SAME name diff --git a/tests/Reflection/ClassSpecializerTest.php b/tests/Reflection/ClassSpecializerTest.php index 466e3ee..2819780 100644 --- a/tests/Reflection/ClassSpecializerTest.php +++ b/tests/Reflection/ClassSpecializerTest.php @@ -369,10 +369,10 @@ public function testSpecializedClassSurvivesExplicitEngineTeardown(): void $this->assertSame(11, $instance->getValue()); unset($instance); - // Deleting the class-table bucket runs destroy_zend_class() with refcount 1: - // the full user-class teardown (tables, own infos/constants, owned names) is - // exercised NOW instead of at request shutdown - \ZEngine\Core::$executor->classTable->delete(strtolower($newName)); + // Evicting runs destroy_zend_class() with refcount 1: the full user-class + // teardown (tables, own infos/constants, owned names) is exercised NOW + // instead of at request shutdown + $this->assertTrue((new ClassSpecializer())->evict($newName)); $this->assertFalse(class_exists($newName, false)); // The template stays fully intact: shared bodies, names and types survived @@ -382,4 +382,16 @@ public function testSpecializedClassSurvivesExplicitEngineTeardown(): void $this->assertInstanceOf(\ReflectionNamedType::class, $originalType); $this->assertSame(self::PLACEHOLDER, $originalType->getName()); } + + public function testEvictingAnUnknownNameReportsFalse(): void + { + $this->assertFalse((new ClassSpecializer())->evict('ZEngine\Stub\Specialized\NeverRegistered')); + } + + public function testEvictingAnInternalClassIsRejected(): void + { + $this->expectException(ClassSpecializationException::class); + $this->expectExceptionMessage('Cannot evict internal class'); + (new ClassSpecializer())->evict(\SplStack::class); + } } From b1c0b9224098850d4ca9532f820af5b803941421 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:14:37 +0000 Subject: [PATCH 2/3] feat: boot the engine from Composer's autoloader, preload stage included Closes #21, open since 2019 on one blocker recorded in its own comments: 'the composer autoloader calls Core::init() before preload initialization'. An unconditional boot binds the definitions with FFI::cdef(), which lasts for the preload request only, and leaves an engine behind that turns the script's own Core::preload() into a no-op - the server starts and every request after it fails. bootstrap.php (autoload.files) therefore recognises the preload stage and serves it with Core::preload(), whose FFI::load() publishes the definitions under FFI_SCOPE for the life of the server; everything else gets Core::init(). The stage is identified by the one fact that distinguishes it - during preloading the script named by opcache.preload is the first file the process included. Verified end to end on a matching PHP line: FFI::scope('ZEngine') resolves in the request that follows, and does not when the preload script skips the boot. A host that cannot run the engine (no ext-ffi, ffi.enable=0, wrong PHP minor, no definitions for the platform) is left uninitialized in silence - throwing from an autoloaded file would break static analysis, FFI-disabled test jobs and composer-time tooling at require. Nothing is lost: Core::init() is idempotent, so code that needs the engine calls it and gets the same explanation this file swallowed. ZENGINE_AUTOBOOT=0 skips the boot entirely. Core::preload() is now idempotent too, so the explicit call an existing opcache.preload script makes after requiring the autoloader stays harmless. Review feedback on the API additions: - Core::isUsable() dropped - init() and isInitialized() are enough - evict() reads the class through ReflectionClass::isUserDefined()/ isImmutable()/isPreloaded() instead of ce_flags off the raw entry; the new isPreloaded() sits beside isImmutable() - the consumer section folded into 'Public APIs never leak CData', keeping the @internal / FFI-CData part Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019TGQqR7ByjHrVSYKVHPrkJ --- AGENTS.md | 27 ++-- README.md | 28 ++--- bootstrap.php | 80 ++++++++++++ composer.json | 5 +- preload.php | 12 +- src/Core.php | 31 ++--- src/Reflection/ClassSpecializer.php | 14 +-- src/Reflection/ReflectionClass.php | 13 ++ tests/AutoBootTest.php | 186 ++++++++++++++++++++++++++++ tests/CoreInitializationTest.php | 18 ++- tests/bootstrap.php | 6 + 11 files changed, 344 insertions(+), 76 deletions(-) create mode 100644 bootstrap.php create mode 100644 tests/AutoBootTest.php diff --git a/AGENTS.md b/AGENTS.md index af6614b..37024d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,25 +211,14 @@ inside the module; consumers see plain PHP values and framework wrapper objects it or convert it. This is what keeps the FFI blast radius confined to code that is audited for it. -## Consuming z-engine from another package - -Dependant packages (userland-php-generics is the reference consumer) talk to z-engine through -its documented public API and nothing else: - -- **Lifecycle**: `Core::init()`, `Core::preload()`, `Core::isInitialized()` (never probe - `isset(Core::$executor)` — the wrappers are assigned mid-boot, before the layout checks - pass, so the probe reports a half-booted bridge as ready), and `Core::isUsable()` for - "should the engine paths run in this environment at all" (never re-derive that from - `ini_get('ffi.enable')` — a boolean filter rejects the supported `preload` mode). -- **Services and wrappers**: `ClassSpecializer` (including `evict()`), `HotSwap`, - `PersistentHeap`, the `Reflection\*` and `Type\*` wrapper objects, and the substitution - value objects (`TypeSubstitutionMap`, `SlotSubstitutionMap`, `TypeSlot`). - -Off-limits to dependants, without exception: the engine-global wrappers -`Core::$executor` / `Core::$compiler` / `Core::$modules`, every method marked `@internal`, -and anything returning a raw `FFI\CData`/`FFI\CType`. If a dependant needs an operation that -only exists behind that line — as happened with class-table eviction and struct sizes — the -fix is a named public API here, not a reach-through there. +The same line holds for packages built **on** z-engine. What is off-limits to them is +every method marked `@internal` and anything handing out a raw `FFI\CData`/`FFI\CType` +(`Core::type()`, the `getRaw*()` escape hatches) — plus the engine-global wrappers +`Core::$executor` / `Core::$compiler` / `Core::$modules`, which are core-layer state and +not a consumer API. When a dependant needs an operation that only exists behind that +line, the fix is a named public method here, not a reach-through there: class-table +eviction became `ClassSpecializer::evict()` and `sizeof(type(...))` became +`Core::sizeOfType()` for exactly that reason. ## Engine structs are owned by their reflection/type class, never poked from call sites diff --git a/README.md b/README.md index 769dd28..b246ea6 100644 --- a/README.md +++ b/README.md @@ -78,17 +78,20 @@ allocations. composer require lisachenko/z-engine ``` -Initialize the library once, early in your bootstrap: - -```php -use ZEngine\Core; - -require __DIR__ . '/vendor/autoload.php'; - -Core::init(); -``` - -For web (non-CLI) usage, enable FFI preloading by calling `Core::preload()` from the script named in your `opcache.preload` — this loads the engine definitions once at server start instead of per request. +There is nothing to initialize: the engine bridge is booted from Composer's autoloader, so +`require __DIR__ . '/vendor/autoload.php'` is all a consumer needs. That includes the +`opcache.preload` stage, which the bootstrap recognises and serves by publishing the engine +definitions for the life of the server rather than for the preload request alone — pointing +`opcache.preload` at a script that only requires the autoloader is enough to get the +per-request cost down. + +`Core::init()` remains public, idempotent and re-invocable, for the cases that want it: booting +explicitly at a chosen point, re-booting after `Core::shutdown()` inside a live worker, and +turning "the engine is not available here" into its explanation. On a host that cannot run the +engine at all — no ext-ffi, `ffi.enable=0`, an unsupported PHP minor or platform — autoloading +stays silent and leaves `Core` uninitialized, so static analysis and test suites still load the +package; ask `Core::isInitialized()` for the state, or call `Core::init()` to get the reason. +Set `ZENGINE_AUTOBOOT=0` to skip the automatic boot entirely. ### Hello, impossible @@ -96,13 +99,10 @@ For web (non-CLI) usage, enable FFI preloading by calling `Core::preload()` from + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine; + +/** + * Boots the engine bridge from Composer's autoloader, so consumers get an initialized Core + * (issue #21) + * + * Registered through `autoload.files`, which means it runs once per process, before any + * consumer code - and, because Composer orders dependencies first, before the `files` of every + * package that depends on z-engine. A dependant should never have to know how the bridge is + * started; it just uses `Core`, the `Reflection\*` wrappers and the services built on them. + * + * ## Preloading is the whole reason this file has logic in it + * + * `opcache.preload` runs a script at server start whose first act is `require vendor/autoload.php` + * - which lands here. Booting unconditionally at that moment is what kept this issue open since + * 2019: `Core::init()` would bind the definitions with `FFI::cdef()`, which is scoped to the + * *preload request* and gone by the time the first real request arrives, and because that leaves + * a bound engine behind, the `Core::preload()` the script calls next would find its work already + * done and never register the persistent scope. Every following request then fails, with the + * preload script looking correct. + * + * So the preload stage has to be recognised and served differently: `Core::preload()` there + * (`FFI::load()`, which is what publishes the definitions under `FFI_SCOPE` for the life of the + * server), plain `Core::init()` everywhere else - which picks those definitions up through + * `FFI::scope()` when preloading ran, and falls back to `FFI::cdef()` when it did not. + * + * The stage is identified by the one fact that distinguishes it: during preloading the script + * named by `opcache.preload` is the first file the process included. In a request the entry + * script holds that position. + * + * ## Failure is silent here, and explained where it matters + * + * A host without ext-ffi, with `ffi.enable=0`, on an unsupported PHP minor or without generated + * definitions for its platform cannot run the engine - but it can still legitimately autoload + * this package: static analysis, a test suite whose engine-driving cases self-skip, `composer + * install` running its own tooling. Throwing from an autoloaded file would break all of them at + * `require`, so a boot that cannot happen leaves `Core` uninitialized and says nothing. + * + * Nothing is lost by that silence: `Core::init()` is idempotent and re-invocable, so code that + * actually needs the engine calls it and gets either a no-op or the same explanatory + * `RuntimeException` this file swallowed. Ask `Core::isInitialized()` to test the state without + * committing to it. + * + * Set `ZENGINE_AUTOBOOT=0` to skip this entirely and boot by hand. + */ +(static function (): void { + if (getenv('ZENGINE_AUTOBOOT') === '0' || Core::isInitialized()) { + return; + } + + $preloadScript = (string) ini_get('opcache.preload'); + $includedFiles = get_included_files(); + $isPreloadStage = $preloadScript !== '' + && isset($includedFiles[0]) + && realpath($preloadScript) === $includedFiles[0]; + + try { + if ($isPreloadStage) { + Core::preload(); + } else { + Core::init(); + } + } catch (\Throwable) { + // This host cannot run the engine. Core stays uninitialized and Core::init() will + // explain why to whoever actually needs it - see the note above. + } +})(); diff --git a/composer.json b/composer.json index 80311fe..f1e1753 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,10 @@ "autoload": { "psr-4": { "ZEngine\\": "src/" - } + }, + "files": [ + "bootstrap.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/preload.php b/preload.php index 8a2640b..6c5bdd2 100644 --- a/preload.php +++ b/preload.php @@ -9,13 +9,13 @@ */ declare(strict_types=1); -include __DIR__.'/vendor/autoload.php'; - -use ZEngine\Core; - /** * This file should be loaded during the preload stage, which is defined by opcache.preload file. - * Either include it manually, or just add following line into your init section. + * + * Requiring the autoloader is the whole script: z-engine's bootstrap recognises the preload + * stage and publishes the engine definitions under FFI_SCOPE for the life of the server. The + * explicit `Core::preload()` this file used to make is now redundant - it stays supported and + * idempotent for scripts that already call it. */ -Core::preload(); +include __DIR__.'/vendor/autoload.php'; diff --git a/src/Core.php b/src/Core.php index b063dd7..b81fd85 100644 --- a/src/Core.php +++ b/src/Core.php @@ -335,9 +335,17 @@ public static function init(): void /** * Preloads definition and Core for ffi.preload mode, should be called during preload stage for better performance + * + * Idempotent, like init(): bootstrap.php already runs this when it recognises the preload + * stage, so the explicit call an existing opcache.preload script makes right after + * `require vendor/autoload.php` finds the definitions published and returns. */ public static function preload(): void { + if (self::$initialized) { + return; + } + self::assertSupportedEnvironment(); // The generated header is fully preprocessed and carries FFI_SCOPE, so // it can be loaded as-is @@ -366,29 +374,6 @@ public static function isInitialized(): bool return self::$initialized; } - /** - * Whether this environment can boot z-engine at all: ext-ffi loaded, ffi.enable set to a - * working value (`1` or `preload` - the latter needs an opcache.preload script calling - * Core::preload()), a supported PHP minor, and generated engine definitions for this - * platform. - * - * The non-throwing projection of the boot guard: init() explains a refusal, this one - * reports it. Dependants and test bootstraps that need "should the engine paths run - * here?" ask this instead of re-deriving the rule from ini_get('ffi.enable') - which is - * spelled several different ways and means different things per SAPI, so a hand-rolled - * check is wrong somewhere (a boolean filter rejects the supported `preload` mode). - */ - public static function isUsable(): bool - { - try { - self::assertSupportedEnvironment(); - } catch (RuntimeException) { - return false; - } - - return true; - } - /** * Refuses to boot on any PHP build this branch has no verified structure definitions for. */ diff --git a/src/Reflection/ClassSpecializer.php b/src/Reflection/ClassSpecializer.php index 8e44c1e..055850c 100644 --- a/src/Reflection/ClassSpecializer.php +++ b/src/Reflection/ClassSpecializer.php @@ -153,22 +153,18 @@ class_exists($sourceClassName); public function evict(string $className): bool { $lowerName = strtolower($className); - $classValue = Core::$executor->classTable->find($lowerName); - if ($classValue === null) { + $classEntry = $this->findClassEntry($className); + if ($classEntry === null) { return false; } - $classEntry = $classValue->getRawClass(); - $sourceKind = $classEntry->type; - assert(is_string($sourceKind)); - if (ord($sourceKind) !== Core::ZEND_USER_CLASS) { + $registeredClass = ReflectionClass::fromCData($classEntry); + if (!$registeredClass->isUserDefined()) { throw new ClassSpecializationException( "Cannot evict internal class {$className}: only userland classes are supported", ); } - $classFlags = $classEntry->ce_flags; - assert(is_int($classFlags)); - if (($classFlags & (Core::ZEND_ACC_IMMUTABLE | Core::engineConstant('ZEND_ACC_PRELOADED'))) !== 0) { + if ($registeredClass->isImmutable() || $registeredClass->isPreloaded()) { throw new ClassSpecializationException( "Cannot evict {$className}: its class entry lives in shared memory, which this " . 'process must never dismantle', diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php index f2008f9..eea286b 100644 --- a/src/Reflection/ReflectionClass.php +++ b/src/Reflection/ReflectionClass.php @@ -482,6 +482,19 @@ public function isImmutable(): bool return ($this->getFlags() & Core::ZEND_ACC_IMMUTABLE) !== 0; } + /** + * Whether this class entry came from an opcache preload region + * + * A preloaded entry is shared memory that is republished into every request of the worker + * process rather than rebuilt, so - unlike an ordinary immutable entry, which can be copied + * out per request - its class-table bucket outlives any request-memory replacement put in + * its place. That makes it the one shape neither copy-out nor eviction may touch. + */ + public function isPreloaded(): bool + { + return ($this->getFlags() & Core::ZEND_ACC_PRELOADED) !== 0; + } + /** * Copies this opcache-shared (immutable) class entry out of shared memory and rebinds * this reflection to the writable per-process copy diff --git a/tests/AutoBootTest.php b/tests/AutoBootTest.php new file mode 100644 index 0000000..066a2ce --- /dev/null +++ b/tests/AutoBootTest.php @@ -0,0 +1,186 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * Covers bootstrap.php, the autoload.files entry that boots the bridge (issue #21) + * + * Every case here needs its own process: the thing under test happens once, at autoload time, + * before any test code exists. In-process assertions can only see the result of the boot this + * process already did. + * + * The preload case is the one that matters. Automatic initialization was proposed in 2019 and + * abandoned because "the composer autoloader calls Core::init() before preload initialization" - + * an unconditional boot binds the definitions with FFI::cdef(), which lasts for the preload + * request only, and leaves an engine behind that makes the script's own Core::preload() a no-op. + * The server then starts and every request afterwards fails. That failure is invisible to any + * in-process test, so it gets a child process with opcache.preload actually set. + */ +#[Group('opcache')] +final class AutoBootTest extends TestCase +{ + protected function setUp(): void + { + if (!extension_loaded('Zend OPcache')) { + self::markTestSkipped('Preloading needs the opcache extension'); + } + } + + /** + * The regression issue #21 was closed on: preloading has to reach the next request + * + * The probe never calls init() or preload() itself and never registers an autoloader, so a + * ready bridge can only come from the preload stage having published the definitions under + * FFI_SCOPE - which is exactly what FFI::cdef() would not do. + */ + public function testPreloadingThroughTheAutoloaderServesTheFollowingRequest(): void + { + $result = $this->runWithPreload( + dirname(__DIR__) . '/preload.php', + 'echo \ZEngine\Core::isInitialized() ? "booted" : "not booted";', + ); + + self::assertSame('', $result['stderr'], 'the preload stage reported an error'); + self::assertSame(0, $result['exit']); + self::assertSame('booted', $result['stdout']); + } + + /** + * The explicit call an existing preload script still makes must stay harmless + */ + public function testAnExplicitPreloadCallOnTopOfTheAutomaticOneIsHarmless(): void + { + $script = $this->writeScratchPreloadScript('\ZEngine\Core::preload();'); + + try { + $result = $this->runWithPreload( + $script, + 'echo \ZEngine\Core::isInitialized() ? "booted" : "not booted";', + ); + + self::assertSame('', $result['stderr']); + self::assertSame('booted', $result['stdout']); + } finally { + @unlink($script); + } + } + + public function testAutoBootCanBeDisabled(): void + { + $result = $this->runProbe( + 'echo \ZEngine\Core::isInitialized() ? "booted" : "not booted";', + ['ZENGINE_AUTOBOOT' => '0'], + ); + + self::assertSame('not booted', $result['stdout']); + } + + /** + * A host that cannot run the engine still has to be able to autoload the package + */ + public function testAutoloadingIsSilentWhenTheEngineCannotBoot(): void + { + $result = $this->runProbe( + 'echo \ZEngine\Core::isInitialized() ? "booted" : "not booted";', + [], + ['-d', 'ffi.enable=0'], + ); + + self::assertSame('', $result['stderr'], 'autoloading must not fail on a host without usable FFI'); + self::assertSame(0, $result['exit']); + self::assertSame('not booted', $result['stdout']); + } + + private function writeScratchPreloadScript(string $body): string + { + $path = sys_get_temp_dir() . '/z-engine-preload-' . getmypid() . '.php'; + file_put_contents($path, sprintf( + "currentUserName(); + if ($user !== null) { + $options[] = '-d'; + $options[] = 'opcache.preload_user=' . $user; + } + + return $this->runProbe($probe, [], $options); + } + + /** + * @param array $environment + * @param list $options + * @return array{exit: int, stdout: string, stderr: string} + */ + private function runProbe(string $probe, array $environment = [], array $options = []): array + { + $command = [ + PHP_BINARY, + '-d', 'ffi.enable=1', + '-d', 'opcache.enable=1', + '-d', 'opcache.enable_cli=1', + // The JIT rewrites the executor internals z-engine hooks into + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + '-d', 'display_errors=stderr', + '-d', 'error_reporting=-1', + ...$options, + '-r', + sprintf('require %s; %s', var_export(dirname(__DIR__) . '/vendor/autoload.php', true), $probe), + ]; + + $process = proc_open( + $command, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + $environment === [] ? null : [...getenv(), ...$environment], + ); + self::assertIsResource($process, 'could not start a child PHP process'); + + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return ['exit' => proc_close($process), 'stdout' => trim($stdout), 'stderr' => trim($stderr)]; + } + + private function currentUserName(): ?string + { + if (!function_exists('posix_geteuid') || posix_geteuid() !== 0) { + return null; + } + $entry = function_exists('posix_getpwuid') ? posix_getpwuid(0) : false; + + return $entry === false ? 'root' : $entry['name']; + } +} diff --git a/tests/CoreInitializationTest.php b/tests/CoreInitializationTest.php index 196cc96..4dfc46c 100644 --- a/tests/CoreInitializationTest.php +++ b/tests/CoreInitializationTest.php @@ -18,15 +18,16 @@ /** * Covers the boot-state introspection of Core: whether the engine bridge is ready. * - * The negative (pre-init) case is not testable here: the suite bootstrap calls - * Core::init(), and PHPUnit's process isolation re-runs that bootstrap in every - * child process, so no test ever observes an uninitialized Core. + * The negative (pre-init) case is not testable here: autoloading boots the bridge and + * PHPUnit's process isolation re-runs the bootstrap in every child process, so no test + * ever observes an uninitialized Core. */ final class CoreInitializationTest extends TestCase { public function testIsInitializedAfterSuiteBootstrap(): void { - // tests/bootstrap.php called Core::init() for this process + // Both the autoload bootstrap and the suite's own explicit init ran for this process; + // the automatic path is proven separately, in AutoBootTest's child processes $this->assertTrue(Core::isInitialized(), 'the suite bootstrap initialized the engine bridge'); } @@ -37,4 +38,13 @@ public function testInitIsReInvocableWhenAlreadyInitialized(): void Core::init(); $this->assertTrue(Core::isInitialized()); } + + public function testPreloadIsIdempotentAfterTheAutomaticBoot(): void + { + // An existing opcache.preload script calls Core::preload() right after requiring the + // autoloader, which has already published the definitions. The second call has to be + // a no-op rather than a second FFI::load(). + Core::preload(); + $this->assertTrue(Core::isInitialized()); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 146c39d..7041eb4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,6 +15,12 @@ ini_set('display_errors', 'on'); +// Requiring the autoloader is the whole boot - bootstrap.php runs from autoload.files. include __DIR__ . '/../vendor/autoload.php'; +// That boot is deliberately silent on a host that cannot run the engine, which is right for a +// library but wrong for this suite: every test here drives the engine, so an unsupported host +// has to say so now rather than through 900 confusing failures. init() is idempotent, so after +// a successful auto-boot this is a no-op. AutoBootTest proves the automatic path in child +// processes, where it can be observed without this line interfering. Core::init(); From c8c8dfa562297486300dbe4d9dd41b7feec61009 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:17:42 +0000 Subject: [PATCH 3/3] fix: skip the preload autoboot cases on Windows PHP does not support opcache preloading on Windows at all, so both preload cases failed there: the engine reported 'Preloading is not supported on Windows', and the scratch script path went through a short name (RUNNER~1) whose tilde broke -d ini parsing before that. Guarded with the same skip the repository already uses for opcache.preload (issue #119), on the two cases that need it rather than the whole class - the opt-out and silent-failure cases are plain autoload behaviour and keep running everywhere. The --fail-on-skipped opcache gate runs on Linux and macOS, where preloading exists and neither case skips. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019TGQqR7ByjHrVSYKVHPrkJ --- tests/AutoBootTest.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/AutoBootTest.php b/tests/AutoBootTest.php index 066a2ce..8fea44b 100644 --- a/tests/AutoBootTest.php +++ b/tests/AutoBootTest.php @@ -33,11 +33,18 @@ #[Group('opcache')] final class AutoBootTest extends TestCase { - protected function setUp(): void + /** + * Only the two preload cases need it; the opt-out and silent-failure cases are plain + * autoload behaviour and run everywhere + */ + private function requirePreloadSupport(): void { if (!extension_loaded('Zend OPcache')) { self::markTestSkipped('Preloading needs the opcache extension'); } + if (PHP_OS_FAMILY === 'Windows') { + self::markTestSkipped('opcache.preload is not available on Windows (issue #119)'); + } } /** @@ -49,6 +56,8 @@ protected function setUp(): void */ public function testPreloadingThroughTheAutoloaderServesTheFollowingRequest(): void { + $this->requirePreloadSupport(); + $result = $this->runWithPreload( dirname(__DIR__) . '/preload.php', 'echo \ZEngine\Core::isInitialized() ? "booted" : "not booted";', @@ -64,6 +73,8 @@ public function testPreloadingThroughTheAutoloaderServesTheFollowingRequest(): v */ public function testAnExplicitPreloadCallOnTopOfTheAutomaticOneIsHarmless(): void { + $this->requirePreloadSupport(); + $script = $this->writeScratchPreloadScript('\ZEngine\Core::preload();'); try {