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
41 changes: 41 additions & 0 deletions docs/opcache-binary.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,43 @@ string literal, a new constant value — are written correctly, not just in-plac
byte pokes. `refresh()` is `save()` plus `opcache_invalidate()` on the source
script, so the next include picks up the patched binary.

## Growing the graph: added functions and methods

In-place edits go out through `PayloadRelocator::derelocate()` — the exact
inverse of the read-time relocation. Mutations that outgrow the original
buffer take a different writer
([#117](https://github.com/lisachenko/z-engine/issues/117)):
`ScriptSerializer`, a two-pass port of `zend_persist_calc` → `zend_persist`
(pass 1 walks the graph, deduplicating every reachable allocation unit
through an xlat table and summing aligned sizes; pass 2 emits a fresh
contiguous region and rewrites every pointer), which then delegates the
on-disk offset encoding to the same `PayloadRelocator` serialize stage — one
implementation for the offset format. `save()` picks the writer
automatically: it re-emits from scratch once the reflection view reports the
graph as grown, and keeps the byte-exact derelocate path otherwise.

New code enters the image as **grafts from donor binaries**:

```php
$file = BinaryCacheFile::read($binPath, $scriptPath);
$donor = BinaryCacheFile::compile($donorScript, $donorCacheDir);

$view = $file->getReflection();
$view->addFunctionFrom($donor->getReflection(), 'my_new_function');
$view->addMethodFrom($donor->getReflection(), 'DonorClass', 'newMethod', 'CachedClass');
$file->save(); // a fresh worker now executes the added function and method
```

Donors are compiled by a real opcache child, so their op_arrays are already
in file form (opline handlers are handler-table indexes, IS_CONST operands
are literal-table indexes — neither is derivable in-process without engine
helpers that are not exported); the serializer copies those units verbatim.
Grafting regrows the target hashtable outside the buffer — persisted tables
must never be touched by `zend_hash_add`, their data block is not an
emalloc'd allocation — and the donor image stays referenced (and, for
methods, mutated: the op_array's scope is re-pointed at the adopting class)
until `save()` re-emits everything into one fresh region.

## Refresh and shared memory

Under `opcache.file_cache_only=1` there is no shared-memory copy, so writing the
Expand Down Expand Up @@ -220,6 +257,10 @@ $report->appliedMethods; // what actually happened, per entry
attributes (including constant-expression arguments), static variables,
compile warnings, try/catch and enums are supported and round-trip
byte-for-byte.
- **Graph growth.** Added functions and methods are supported through donor
grafts and the from-scratch `ScriptSerializer` (see "Growing the graph"
above, issue #117); whole added classes and freshly in-process compiled
op_arrays (no file-form oplines) remain out of scope and are refused loudly.
- **Deferred.** Loading patched binaries into shared memory (ZCSG,
[#121](https://github.com/lisachenko/z-engine/issues/121)). Applying a
patched image to already-loaded functions and classes landed as
Expand Down
29 changes: 28 additions & 1 deletion phpstan.dist.neon
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,42 @@ parameters:
-
identifier: argument.type
path: src/OpCache/PayloadRelocator.php
# ScriptSerializer is the second audited pointer-surgery file (the
# persist-from-graph writer, issue #117): the same CData field walking
# as PayloadRelocator, covered by the rebuild/graft execute-from-cache
# tests and the relocator round-trip identity checks.
-
identifier: property.nonObject
path: src/OpCache/ScriptSerializer.php
-
identifier: binaryOp.invalid
path: src/OpCache/ScriptSerializer.php
-
identifier: cast.int
path: src/OpCache/ScriptSerializer.php
-
identifier: argument.type
path: src/OpCache/ScriptSerializer.php
# ReflectionOpcacheFile is the CData facade over the relocated script:
# it reads embedded engine structs (script.filename, function/class
# tables) that resolve to `mixed` after the first CData hop.
# tables) that resolve to `mixed` after the first CData hop; since
# issue #117 it also carries the graft plumbing (image hashtable
# regrowth), which does the same CData arithmetic.
-
identifier: property.nonObject
path: src/OpCache/ReflectionOpcacheFile.php
-
identifier: argument.type
path: src/OpCache/ReflectionOpcacheFile.php
-
identifier: binaryOp.invalid
path: src/OpCache/ReflectionOpcacheFile.php
-
identifier: cast.int
path: src/OpCache/ReflectionOpcacheFile.php
-
identifier: assignOp.invalid
path: src/OpCache/ReflectionOpcacheFile.php
# The dimension tests exist to prove that a plain `count($object)` reaches the engine's
# count_elements handler on a class that never declared the count itself. Rewriting them
# as assertCount() would measure PHPUnit's Count constraint instead of the language
Expand Down
17 changes: 16 additions & 1 deletion src/OpCache/BinaryCacheFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,22 @@ public function getReflection(): ReflectionOpcacheFile
public function save(?string $binPath = null, ?int $timestamp = null, ?int $directoryPermissions = 0o755): void
{
$target = $binPath ?? $this->binPath;
if ($this->relocator !== null) {
if ($this->view !== null && $this->view->isGraphGrown()) {
// A mutation outgrew the original buffer (added function/method,
// regrown hashtable): re-emit the whole graph from scratch through
// the two-pass persist serializer (issue #117). In-place edits keep
// taking the exact-inverse derelocate() path below.
$serializer = new ScriptSerializer($this->view->getRawScript());
$this->payload = $serializer->serialize();
$this->metaInfo = CacheMetaInfo::forPayload(
systemId: $this->metaInfo->systemId(),
memSize: $serializer->memSize(),
strSize: strlen($this->payload) - $serializer->memSize(),
scriptOffset: $serializer->scriptOffset(),
timestamp: $this->metaInfo->timestamp(),
checksum: 0, // recomputed below
);
} elseif ($this->relocator !== null) {
// Re-serialize the (possibly mutated) live image, updating the
// interned-string section size in the header
$this->payload = $this->relocator->derelocate();
Expand Down
25 changes: 25 additions & 0 deletions src/OpCache/OpCacheException.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,29 @@ public static function payloadNotRelocated(): self
{
return new self('The payload is not relocated: call script() before accessing structures');
}

/**
* The graph serializer met a pointer whose target no persisted unit covers -
* the graph references memory the serialization pass never absorbed
*/
public static function unresolvedGraphReference(string $what): self
{
return new self("Graph serialization failed, {$what}: the referenced structure was not persisted");
}

/**
* A graft donor does not contain the requested function/class/method
*/
public static function graftEntryNotFound(string $kind, string $name): self
{
return new self("Cannot graft {$kind} '{$name}': the donor cache image does not contain it");
}

/**
* The graft target hashtable already holds an entry under this key
*/
public static function duplicateHashTableKey(string $key): self
{
return new self("Cannot graft '{$key}': the target table already holds an entry under that key");
}
}
7 changes: 5 additions & 2 deletions src/OpCache/PayloadRelocator.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ public function __construct(private readonly object $buffer, private readonly Ca
$this->base = Core::addressOf(Core::addr($buffer));
$this->size = $metaInfo->memSize();
$this->strSectionBase = $this->base + $this->size;
// _ZSTR_HEADER_SIZE = sizeof(zend_string) - sizeof(char) (the flexible val[1] member)
$this->zendStringHeaderSize = Core::sizeOfType(zend_string::class) - 1;
// _ZSTR_HEADER_SIZE = XtOffsetOf(zend_string, val): the flexible val[1]
// member starts at the last 8-byte slot of the (padded) struct, so the
// header is sizeof - 8, NOT sizeof - 1 (which over-copied 7 bytes per
// interned emission and diverged from _ZSTR_STRUCT_SIZE)
$this->zendStringHeaderSize = Core::sizeOfType(zend_string::class) - PHP_INT_SIZE;
}

/**
Expand Down
Loading
Loading