diff --git a/AGENTS.md b/AGENTS.md index caecb0c..37024d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,15 @@ 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. +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 This applies to EVERY class: if a class is responsible for a structure, then all external 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/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/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 3648ed1..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 @@ -615,6 +623,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 +938,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..055850c 100644 --- a/src/Reflection/ClassSpecializer.php +++ b/src/Reflection/ClassSpecializer.php @@ -128,6 +128,54 @@ 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); + $classEntry = $this->findClassEntry($className); + if ($classEntry === null) { + return false; + } + + $registeredClass = ReflectionClass::fromCData($classEntry); + if (!$registeredClass->isUserDefined()) { + throw new ClassSpecializationException( + "Cannot evict internal class {$className}: only userland classes are supported", + ); + } + if ($registeredClass->isImmutable() || $registeredClass->isPreloaded()) { + 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/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..8fea44b --- /dev/null +++ b/tests/AutoBootTest.php @@ -0,0 +1,197 @@ + + * + * 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 +{ + /** + * 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)'); + } + } + + /** + * 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 + { + $this->requirePreloadSupport(); + + $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 + { + $this->requirePreloadSupport(); + + $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/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); + } } 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();