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
43 changes: 43 additions & 0 deletions docs/persistent-heap.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,49 @@ Persistent strings are interned-style (immutable, non-refcounted), persistent ar
sealed immutable tables — both are copied into zvals without refcounting and any userland
mutation copy-on-writes into request memory, leaving the persistent block untouched.

## Where the persistent bytes come from (the allocator seam)

Every persistent block the framework mints — an object clone, an interned string block, a
hashtable struct — is allocated through a `ZEngine\Memory\Allocator`. The default is
`EngineAllocator`, which reproduces the three shapes z-engine used to hardcode (tracked
malloc, tracked request memory, untracked malloc), so a caller that passes nothing sees no
change at all.

Passing one is what lets a graph live somewhere other than the process heap — a
fork-shared mmap arena, a shared-memory segment:

```php
$graph = (new PersistentGraphCloner($arena))->persist($root); // objects + strings + tables
$clone = PersistentObjectFactory::persistentClone($rawObject, $arena); // one object
$block = StringEntry::persistentInterned('key', $arena); // one string
$table = new PersistentHashTable($arena); // one table struct
```

The interface speaks in **addresses**, never `FFI\CData` (AGENTS.md): an implementation in
a consumer package binds its own `mmap`/`shm` primitives with `FFI::cdef` and returns the
integer it computed. It also reports whether it keeps ownership of what it hands out —
`ownsAllocations()`. A structure built on such memory refuses `destroy()`: both frees
assume z-engine's own allocator, and the arena owner releases the region as a whole.

A table's struct is only half its memory; the buckets are the other half, and the engine
would `pemalloc` them on the first insert. `PersistentHashTable::installExternalStorage()`
takes that half too:

```php
$capacity = 1024; // a power of two
$address = $arena->allocate(PersistentHashTable::externalStorageSize($capacity));
$table = PersistentHashTable::withExternalStorage($address, $capacity, $arena);
```

The installed block carries the engine's own `HT_SIZE_EX` layout (two `uint32_t` hash slots
per bucket, reset to `HT_INVALID_IDX`, followed by the `Bucket` area), and installation is
only allowed **before the first insert**, so the engine never allocates storage of its own.
Growth is the hazard afterwards — the engine grows a full table by `perealloc`ing exactly
that block — and is guarded from both sides: inserts through the wrapper refuse the write
that would trigger the resize (`getRemainingCapacity()` reports the headroom), and
`assertNoGrowth()` diagnoses a relocation caused by engine paths the wrapper cannot
intercept.

## Storage layout and the anchor

Everything the heap needs across requests lives in engine-visible persistent memory —
Expand Down
17 changes: 17 additions & 0 deletions src/Core.php
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,23 @@ public static function free(object $variable): void
FFI::free(self::toCData($variable));
}

/**
* Returns the byte offset of a field inside an engine structure
*
* The named form of the type(...)->getStructFieldOffset(...) pair, and the one consumers
* should use: it answers "where do the inline properties start in a zend_object?" without
* a raw FFI\CType ever crossing the API boundary - the same remedy sizeOfType() is for
* sizeof(type(...)).
*
* @param class-string|string $type Name of the engine type (eg "zend_object") or a
* generated struct stub class
* @param string $field Name of the declared field
*/
public static function offsetOfField(string $type, string $field): int
{
return self::$engine->type(self::resolveCName($type))->getStructFieldOffset($field);
}

/**
* Returns a CType definition for engine by type name
*
Expand Down
51 changes: 51 additions & 0 deletions src/Memory/AllocationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/**
* Z-Engine framework
*
* @copyright Copyright 2026, 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\Memory;

/**
* Raised when an Allocator cannot satisfy a request
*
* These failures happen BEFORE a single byte is handed out, so a rejected allocation
* leaves neither the allocator nor the structure that asked for memory in a half-built
* state. Every failure mode has a named static constructor (project convention, see
* AGENTS.md).
*/
class AllocationException extends \RuntimeException
{
/**
* Raised when the requested block size is not a positive number of bytes
*/
public static function invalidSize(int $size): self
{
return new self("An allocation size must be a positive number of bytes, {$size} given");
}

/**
* Raised when the requested alignment is not a power of two
*/
public static function invalidAlignment(int $alignment): self
{
return new self("An allocation alignment must be a power of two, {$alignment} given");
}

/**
* Raised when the allocator cannot guarantee the alignment the caller asked for
*/
public static function unsupportedAlignment(int $requested, int $guaranteed): self
{
return new self(
"This allocator guarantees {$guaranteed}-byte alignment, {$requested} bytes were requested",
);
}
}
91 changes: 91 additions & 0 deletions src/Memory/Allocator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

/**
* Z-Engine framework
*
* @copyright Copyright 2026, 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\Memory;

/**
* Source of raw, zeroed memory blocks for the persistent structures z-engine mints
*
* Every persistent primitive of the framework - the object clones of
* PersistentObjectFactory, the interned blocks of StringEntry::persistent(), the struct
* of a PersistentHashTable - allocates through one of these. The default implementation
* (EngineAllocator) is the malloc-backed FFI allocator z-engine has always used, so a
* caller that passes nothing keeps exactly the behavior it had before this seam existed.
*
* The seam exists for sinks that are NOT the process heap: a fork-shared mmap arena, a
* shared-memory segment, a preallocated slab. Such an allocator hands out addresses inside
* memory it owns itself, which is why ownsAllocations() is part of the contract: z-engine
* must never release a block it did not allocate (destroy() refuses instead of calling
* free(3) on somebody else's arena).
*
* The interface deliberately speaks in ADDRESSES rather than FFI\CData: a public API of
* this framework never leaks engine handles (see AGENTS.md), and an implementation living
* in a consumer package can therefore be written against nothing but integers - it binds
* its own mmap/shm primitives with FFI::cdef and returns the address it computed.
*
* Implementation contract:
*
* - the returned block is at least $size bytes long and ZEROED, exactly like
* FFI::new()/pemalloc + memset - engine structures are initialized field by field and
* every field the caller does not write must read as zero;
* - the address is aligned to at least $alignment bytes;
* - the block stays alive at least until the owner releases it: nothing in z-engine
* reclaims blocks of a foreign allocator;
* - allocate() throws (AllocationException or an implementation-specific exception) when
* it cannot satisfy the request; it never returns 0.
*/
interface Allocator
{
/**
* Alignment good enough for every engine structure: what malloc() guarantees on the
* supported platforms (alignof(max_align_t) is 16 on x64 and arm64)
*/
public const int DEFAULT_ALIGNMENT = 16;

/**
* Alignment an engine structure actually REQUIRES: pointer alignment
*
* zend_object, zend_string, HashTable and Bucket are made of pointers, zend_longs and
* doubles - all of them 8-byte-aligned members on every supported platform, which is
* also what the Zend memory manager itself guarantees (ZEND_MM_ALIGNMENT). The
* framework's own allocations ask for exactly this, so a request-lifetime block is a
* legal answer too; DEFAULT_ALIGNMENT stays the safer public default for callers who
* do not want to reason about it.
*/
public const int ENGINE_STRUCT_ALIGNMENT = 8;

/**
* Allocates one zeroed block and returns its ADDRESS
*
* @param int $size Number of bytes to allocate, greater than zero
* @param int $alignment Required alignment of the returned address, a power of two
*
* @return int Address of the block, never zero
*
* @throws AllocationException when the request cannot be satisfied
*/
public function allocate(int $size, int $alignment = self::DEFAULT_ALIGNMENT): int;

/**
* Whether the blocks handed out stay owned by THIS allocator
*
* `true` means the memory belongs to the allocator (an arena, a shared segment): the
* owner reclaims it as a whole and z-engine must never free an individual block - the
* structures built on top refuse their destroy() path instead of guessing an allocator.
*
* `false` means the block was handed over to z-engine's own bookkeeping (the
* tracked-block registry and the persistent allocator behind it), which is what the
* default EngineAllocator does and what every pre-existing caller relies on.
*/
public function ownsAllocations(): bool;
}
126 changes: 126 additions & 0 deletions src/Memory/EngineAllocator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

/**
* Z-Engine framework
*
* @copyright Copyright 2026, 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\Memory;

use ZEngine\Core;

/**
* The allocator z-engine has always used: raw blocks minted through PHP's FFI allocator
*
* Three modes, one per allocation shape the framework needs. They are exactly the three
* calls the persistent primitives used to hardcode, so passing the matching instance (or
* nothing at all, since every seam defaults to it) reproduces the previous behavior
* byte for byte:
*
* - trackedPersistent() - Core::trackedNew(..., persistent) -> pemalloc(size, 1), i.e.
* plain malloc, recorded in the tracked-block registry so untrackAndFree() may release
* it later through the very allocator that minted it. The allocation class of persistent
* object clones and of persistent hashtable structs.
* - trackedRequest() - Core::trackedNew(..., request) -> emalloc, registry-tracked. The
* allocation class of an ordinary owned HashTable, which dies with the request.
* - persistent() - Core::new(..., owned: false, persistent: true), malloc-backed and
* deliberately NOT tracked: the allocation class of persistent (interned) zend_strings,
* which are immortal by design and are never handed to untrackAndFree().
*
* Instances are stateless, so each mode is a process-wide singleton; identity comparison
* against them is a legitimate way to ask "is this the default allocator?".
*
* Blocks are handed over to z-engine's own bookkeeping, never kept by the allocator, so
* ownsAllocations() is false: the structures built on top may release them (destroy()).
*/
final class EngineAllocator implements Allocator
{
/**
* What malloc() guarantees for the persistent modes (alignof(max_align_t))
*/
private const int MALLOC_ALIGNMENT = 16;

/**
* What the Zend memory manager guarantees for the request mode (ZEND_MM_ALIGNMENT)
*/
private const int REQUEST_ALIGNMENT = 8;

/**
* One shared instance per mode, keyed "tracked?persistent?"
*
* @var array<string, self>
*/
private static array $instances = [];

private function __construct(
private readonly bool $persistent,
private readonly bool $tracked,
) {}

/**
* Malloc-backed blocks recorded in the tracked-block registry (the persistent default)
*/
public static function trackedPersistent(): self
{
return self::$instances['tracked-persistent'] ??= new self(persistent: true, tracked: true);
}

/**
* Request-lifetime blocks recorded in the tracked-block registry (the plain default)
*/
public static function trackedRequest(): self
{
return self::$instances['tracked-request'] ??= new self(persistent: false, tracked: true);
}

/**
* Malloc-backed blocks OUTSIDE the tracked-block registry (immortal by design)
*/
public static function persistent(): self
{
return self::$instances['untracked-persistent'] ??= new self(persistent: true, tracked: false);
}

/**
* @inheritDoc
*/
#[\Override]
public function allocate(int $size, int $alignment = Allocator::DEFAULT_ALIGNMENT): int
{
if ($size <= 0) {
throw AllocationException::invalidSize($size);
}
if ($alignment <= 0 || ($alignment & ($alignment - 1)) !== 0) {
throw AllocationException::invalidAlignment($alignment);
}
$guaranteed = $this->persistent ? self::MALLOC_ALIGNMENT : self::REQUEST_ALIGNMENT;
if ($alignment > $guaranteed) {
throw AllocationException::unsupportedAlignment($alignment, $guaranteed);
}

// FFI::new() zeroes whatever it allocates, in both allocation classes, which is
// the "block reads as zero" half of the Allocator contract
$block = $this->tracked
? Core::trackedNew("char[{$size}]", $this->persistent)
: Core::new("char[{$size}]", false, $this->persistent);

// The registry keys blocks by the address of the buffer itself, so the same number
// is what a later untrackAndFree()/persistentFree() has to be given
return Core::addressOf(Core::addr($block));
}

/**
* @inheritDoc
*/
#[\Override]
public function ownsAllocations(): bool
{
return false;
}
}
Loading
Loading