Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
288f548
feat(shm): fork-shared mmap arena with a robust pshared mutex bank
claude Aug 15, 2026
717b84a
chore: pin z-engine to the local allocator-seam branch while it is in…
claude Aug 15, 2026
93a88b1
feat(shm): persist object graphs into the arena and move the registry…
claude Aug 15, 2026
7fb0c03
test(tests): fork-based coverage for the arena, its registry and shar…
claude Aug 15, 2026
42f17d4
docs: describe arena mode, its limits, and keep the spike evidence ne…
claude Aug 15, 2026
ee16409
chore: let CI resolve the z-engine seam branch when the sibling check…
claude Aug 15, 2026
e3b02e7
feat(shm): verify the arena header before a recovering worker trusts …
claude Aug 15, 2026
de6705b
fix(ci): resolve z-engine via vcs only so composer install works with…
claude Aug 15, 2026
da16d3b
feat(shm): dedicated arena mutexes and address-hashed stripe selection
claude Aug 15, 2026
047a865
feat(ipc): 16-byte tagged value records and a codec that never encodes
claude Aug 15, 2026
f664ce3
feat(ipc): notification plane of fixed event records over inherited s…
claude Aug 15, 2026
cf61619
feat(ipc): shared channel with rendezvous handoff and cross-process c…
claude Aug 15, 2026
4a8538f
feat(ipc): shared array, mutex, atomic cell and wait group in the arena
claude Aug 15, 2026
42f71ad
feat(ipc): result slots carrying coroutine returns and panics by address
claude Aug 15, 2026
584170c
fix(store): report an arena-backed module through its live store, not…
claude Aug 15, 2026
bd549ba
test(tests): fork-based coverage for channels, slots and the notifica…
claude Aug 15, 2026
8d3ef0b
docs: describe the IPC primitives and what the sockets are allowed to…
claude Aug 15, 2026
ee9c995
fix(composer): track the z-engine release lines now that the seam is …
claude Aug 15, 2026
eb56874
chore(spikes): drop the vendored validation sweep with environment-sp…
claude Aug 15, 2026
1cbc8bb
docs: distill the shared-memory model, limitations and evidence from …
claude Aug 15, 2026
a02eda5
docs: describe how the shared-memory solution is built, not only what…
claude Aug 15, 2026
1df8fe0
fix(shm): refuse every free of arena memory at the last line before it
claude Aug 15, 2026
d2d49c5
feat(registry): record whether a persisted object belongs to a mutabl…
claude Aug 15, 2026
91f40c7
feat(store): per-process side table and an opt-in mutable shared mode
claude Aug 15, 2026
4ffbfed
fix(shm): stop unmapping the arena at request shutdown
claude Aug 15, 2026
11d8d8b
test(tests): promote the mutation, side-table and lifecycle claims to…
claude Aug 15, 2026
e4358d0
docs: document shared mutation and the per-process fields as shipped …
claude Aug 15, 2026
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
179 changes: 178 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,163 @@ graph in `tools/soak-drop.php`, against ~13 kB per cycle if nothing were
reclaimed. A real content-keyed persistent intern table would remove this
residue; it is the next iteration.

### Fork-shared arena mode (opt-in, experimental)

Everything above is **per-worker** memory: each FPM/RoadRunner process rebuilds its own
copy. Arena mode removes that limit for a family of processes that descend from one
parent. The state is persisted into a single `mmap(MAP_SHARED|MAP_ANONYMOUS)` region
created **before the fork**, so every worker sees the very same objects at the very same
addresses — no serialization, no cache round-trip, no copy.

```php
use Lisachenko\SharedData\PersistentStore;
use Lisachenko\SharedData\Shm\Arena;

$arena = Arena::create(); // 64 MB by default, or SHARED_DATA_ARENA_SIZE
$store = PersistentStore::bootShared($arena);

$config = $store->persist(AppConfig::class, buildExpensiveConfig());
$address = $store->addressOf(AppConfig::class); // eight bytes that mean the same thing
// in every process of the family
for ($worker = 0; $worker < 4; $worker++) {
if (pcntl_fork() === 0) {
$childStore = PersistentStore::bootShared($arena); // recovery, no globals write
$config = $childStore->get(AppConfig::class); // the SAME object, not a copy
// ... or attach an address a sibling sent over a socket:
// $object = $childStore->attachObject($address);
exit(0);
}
}
```

What changes under the hood: every block the store mints — registry tables, object clones,
frozen snapshots, interned strings, sealed arrays and their keys — comes out of the arena
instead of malloc. The registry tables are **pre-sized and never grown**: the engine grows
a full hashtable by reallocating its data block into the private heap of whichever worker
filled it, writing that pointer into the shared struct *before* anything fails, so the
tables refuse the insert with a typed `ArenaException` instead. Sizes come from
`SHARED_DATA_ENTRY_CAPACITY` / `SHARED_DATA_OBJECT_CAPACITY`.

The arena is bump-allocated and **leak-until-teardown**: blocks are never returned
individually (`drop()` still removes entries and share-accounts them, it just does not free
arena memory), and the region lives until the creating process exits — nothing unmaps it at
request shutdown, because the engine releases the last references to shared objects *after*
shutdown functions have run. `watermark()`
exposes exactly how much has been handed out, and exhaustion is a typed exception, never a
crash. Cross-process locking uses a bank of 64 `PTHREAD_PROCESS_SHARED | PTHREAD_MUTEX_ROBUST`
mutexes inside the arena, so a SIGKILLed worker hands the lock on (`EOWNERDEAD`) instead of
wedging the pool.

**Per-process engine state.** Three fields of a `zend_object` describe the process reading
it, not the object, and they live in a per-process side table rather than in shared memory:
the object-store `handle` (forked children inherit one free list and are handed *identical*
numbers, so the shared field is overwritten with a sentinel and identity is
`$store->sharedIdOf($object)` — the arena address), the class entry (rebound per process;
classes must still be loaded **before the fork**, since a shared object carries one `ce` for
the family), and the dynamic-property cache. That last one is written by engine C code on
`get_object_vars()`, `var_dump()`, `json_encode()`, `(array)`, `serialize()`,
`debug_zval_dump()` and `ReflectionObject` — a request-heap pointer deposited in shared
memory — so it is forced `NULL` at attach and never dereferenced. Inspect a shared object
through `$store->inspect($object, fn ($o) => var_dump($o))`, or call
`$store->scrubProperties($object)` afterwards.

### Shared mutation (opt-in per graph)

By default a persisted graph is **frozen**: request-time mutations are rolled back at
request end. Pass `mutable: true` and the graph keeps everything that makes a persistent
clone safe — the refcount pin, `GC_PERSISTENT|GC_NOT_COLLECTABLE`, non-refcounted payloads,
sealed arrays — and gives up the rollback, so what a worker writes stays written for the
whole family:

```php
$counters = $store->persist(Counters::class, new Counters(), mutable: true);
$handle = $store->mutableHandle($counters);

$handle->writeScalars(['hits' => 1, 'misses' => 0]); // one critical section
$handle->writeString('lastRoute', '/checkout'); // interned in the arena, pointer swapped
$handle->writeReference('owner', $otherSharedObject); // arena objects only

[$hits, $misses] = array_values($handle->readScalars(['hits', 'misses']));
```

Every write takes the object's stripe mutex and does nothing inside it but store the payload
word and then the type word; every value is validated and interned *before* the lock.
Declared property types are enforced by the write path, because the engine never sees the
assignment. What is refused: a plain-array slot (a shared `zend_array` can never grow — use
`Ipc\SharedArray`), and a reference to an object that is not itself in this arena.

A direct `$object->hits++` still compiles and still reaches shared memory — the extension
rewires shared objects to `std_object_handlers`, so there is no write hook to intercept it.
For scalars that is merely **unsynchronized** (visible everywhere, racy). For a string,
array or object it stores a pointer into the writing process's request heap, which no
sibling may follow: such a slot is restored from the persisted image at detach instead of
being left behind. Use the handle for anything that has to be correct.

Reader/writer contract for anything you build on the arena directly: a naturally aligned
8-byte read never tears, but a 16-byte `zval` is two stores — readers take the same stripe
mutex as the writer whenever a value's *type* can change or more than one slot participates.
Every claim in this section, with its evidence and its consequences, is written up in
[docs/shared-memory-model.md](docs/shared-memory-model.md).

### IPC primitives in the arena (experimental)

Shared memory answers "where does the value live"; it says nothing about "whose turn is it"
and "is it there yet". `Lisachenko\SharedData\Ipc` adds the primitives that do, and they are
themselves structures in the arena — a channel, an array, a mutex, a counter, a wait group
and a table of result slots, all found by address (or by a name in the arena roots
directory) rather than inherited as PHP state.

```php
use Lisachenko\SharedData\Ipc\{SharedChannel, ResultSlotTable, ValueCodec, WakeRegistry};
use Lisachenko\SharedData\Shm\{Arena, ArenaAllocator};

$arena = Arena::create();
$store = PersistentStore::bootShared($arena);
$allocator = new ArenaAllocator($arena);
$codec = new ValueCodec($allocator, $store);
$wake = WakeRegistry::create($arena); // socket pairs, created PRE-FORK
$jobs = SharedChannel::create($allocator, $codec, $wake, 64, name: 'jobs');
$results = ResultSlotTable::create($allocator, $codec, $wake, 1024);

$slot = $results->allocateSlot();
if (pcntl_fork() === 0) {
[$job, $ok] = $jobs->recv(); // parks on the socket, wakes on an event
$results->complete($slot, process($job)); // writes a record, pokes the waiter
exit(0);
}
$jobs->send($sharedObject); // an address, never a copy
$value = $results->await($slot)->value; // read straight out of shared memory
```

Values move as **16-byte records**: `uint8 tag | 7 pad | uint64 payload`, where the payload
is the value itself (`int`, `float`, nothing at all for `null`/`bool`) or an arena address
(an interned `zend_string`, a shared `zend_object`, a `SharedArray`). A value with no
address-shaped form — a plain array, a resource, a closure, an object this family does not
share — is refused with `NotShareableValueException` naming the remedy. Nothing is ever
encoded: there is no `serialize()`, igbinary or JSON on any data path, and the test suite
proves it by shadowing every encoding function in the package's namespaces.

The sockets carry **only** fixed 16-byte event records `{opcode, tag, slot/channel id,
address}` — signalling, never payload; a scalar's record carries a zero where an address
would be. Waking is level-triggered: a waiter registers in the structure's waiter table and
re-checks the state inside the same critical section, so a wakeup can be spurious but never
lost, and every blocking loop also re-polls on a bounded slice.

| Primitive | What it is |
|---|---|
| `SharedChannel` | ring of records + waiter tables under a dedicated robust mutex; capacity 0 is a true cross-process rendezvous; `close()` crosses processes (receivers drain, then `[null, false]`; senders throw) |
| `SharedArray` | fixed-capacity vector of records, `ArrayAccess`/`Countable`/`IteratorAggregate`, stripe-locked |
| `ResultSlotTable` | futures: `allocateSlot()` / `complete()` / `completePanic()` / `await()`, with panics travelling as a shared `SharedError` object |
| `SharedMutex` | robust process-shared mutex with trylock-and-backoff, `EOWNERDEAD` recovered and reported |
| `AtomicInt` | one shared cell: plain aligned get/set, stripe-locked `add()`/`compareAndSet()` |
| `SharedWaitGroup` | counter plus waiter table; `add()`/`done()`/`wait()`, negative counts throw |
| `WakeRegistry` | one inherited socket pair per process, the notification plane everything parks on |

Blocking here is a spin loop over the notification descriptor, which is the honest primitive
a package with no scheduler can offer: every primitive also exposes its non-blocking half
(`trySend()`/`tryRecv()`/`tryLock()`/`readSlot()`) plus `notificationStream()`, so a
coroutine runtime can park a Fiber in its own event loop instead.

### Deployment model

- **Scope: one worker process.** This is per-process persistent memory, not
Expand Down Expand Up @@ -245,13 +402,33 @@ $store->has(User::class): bool;
$store->drop(User::class): bool; // remove the entry + reclaim what nobody shares
$store->objectCount(): int; // live persistent clones (shared ones counted once)
$store->detach(): void; // runs automatically at request shutdown

// fork-shared arena mode (opt-in)
$arena = Arena::create(); // pre-fork, fixed size, leak-until-teardown
$store = PersistentStore::bootShared($arena);
$store->addressOf(User::class): ?int; // the eight bytes that travel between workers
$store->attachObject($address): object; // the receiving half, in any process of the family
$arena->watermark(): int; // arena bytes handed out so far
$arena->contains($address, $length): bool; // is this pointer still shared memory?

// IPC primitives (all of them live in the arena; every one has a non-blocking half)
$wake = WakeRegistry::create($arena); // pre-fork; sockets are inherited
$channel = SharedChannel::create($allocator, $codec, $wake, $capacity);
$channel->send($value, $timeout): bool; // trySend() never blocks
$channel->recv($timeout): array; // [value, true] | [null, false]; tryRecv() too
$channel->close(): void; // crosses processes, drains first
$channel->notificationStream(); // park your own event loop on this
$slots = ResultSlotTable::create($allocator, $codec, $wake, $capacity);
$slots->allocateSlot(): int;
$slots->complete($id, $value): void; // completePanic($id, SharedError::capture(...))
$slots->await($id, $timeout): SlotResult; // readSlot() never blocks
```

## Testing

```bash
composer install
vendor/bin/phpunit # unit + lifecycle tests
vendor/bin/phpunit # unit + lifecycle tests (forking arena suites included)
php -d ffi.enable=1 tools/soak.php # 5k attach/mutate/detach cycles, flat-memory gate
php -d ffi.enable=1 tools/soak-drop.php # 5k persist/attach/drop cycles, reclamation gate
bash tools/request-boundary/run.sh 100 # real RINIT/RSHUTDOWN boundaries via php-cgi/FastCGI
Expand Down
8 changes: 7 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@
"test": "phpunit"
},
"minimum-stability": "dev",
"prefer-stable": true
"prefer-stable": true,
"repositories": [
{
"type": "vcs",
"url": "https://github.com/lisachenko/z-engine"
}
]
}
Loading
Loading