Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,31 +78,31 @@ 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

```php
<?php
declare(strict_types=1);

use ZEngine\Core;
use ZEngine\Reflection\ReflectionClass;

require __DIR__ . '/vendor/autoload.php';

Core::init();

final class Sealed {}

$reflection = new ReflectionClass(Sealed::class);
Expand Down
80 changes: 80 additions & 0 deletions bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

/**
* Z-Engine framework
*
* @copyright Copyright 2019, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* 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.
}
})();
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
"autoload": {
"psr-4": {
"ZEngine\\": "src/"
}
},
"files": [
"bootstrap.php"
]
},
"autoload-dev": {
"psr-4": {
Expand Down
10 changes: 10 additions & 0 deletions docs/class-specialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions preload.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

25 changes: 25 additions & 0 deletions src/Core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
{
Expand Down
48 changes: 48 additions & 0 deletions src/Reflection/ClassSpecializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/Reflection/ReflectionClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading