diff --git a/README.md b/README.md index fb69c8e..c69c9dc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/composer.json b/composer.json index 32674a9..67df89f 100644 --- a/composer.json +++ b/composer.json @@ -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" + } + ] } diff --git a/docs/shared-memory-model.md b/docs/shared-memory-model.md new file mode 100644 index 0000000..89ced82 --- /dev/null +++ b/docs/shared-memory-model.md @@ -0,0 +1,297 @@ +# The shared-memory model: what holds, what breaks, and why + +This document is the distilled result of the validation sweep that preceded the fork-shared +arena (EPIC [#15]) — the questions that had to be answered before any of this could be built, +and the answers, with their consequences for anyone consuming the package. The measurements +quoted here were taken on PHP **8.4.19** and **8.5.9** (NTS, linux-x64) and are recorded in +the [spike-gate verdict on #15][gate]; the claims this package actually depends on are +promoted to tests under `tests/Shm/` and re-run on both minors on every change. + +It is deliberately written as a list of *laws and their symptoms*: most failure modes in this +territory do not raise, they corrupt — quietly, in a different process, much later. + +[#15]: https://github.com/lisachenko/php-shared-data-extension/issues/15 +[gate]: https://github.com/lisachenko/php-shared-data-extension/issues/15#issuecomment-5303807403 + +--- + +## 1. Sharing is fork-only, and that is a design constraint, not an implementation gap + +The arena is one `mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0)` +region created **before any fork**. Anonymous shared memory is shared with the *children* of +the process that mapped it, never with unrelated processes, so the whole worker family +inherits the mapping at the **same virtual address**. That is what makes an address a +portable value: eight bytes handed to a sibling mean the same object there. + +Three further things must be address-stable for a shared `zend_object` to be usable at all, +and all three are stable **only** by virtue of the fork: + +| What | Why it is stable | When it stops being stable | +|---|---|---| +| the arena base | inherited mapping | a process that maps its own arena post-fork gets private memory that merely looks identical | +| `std_object_handlers` | one process-lifetime global, inherited | never (a request-lifetime handlers block would dangle — persistence rejects such objects) | +| `zend_class_entry *` | inherited, if the class was loaded **before** the fork | a class first autoloaded inside one worker lands wherever that worker happened to put it | + +**Consequence.** Every class whose instances travel through the arena must be loaded before +the fork (`opcache.preload`, or simply touching the class). A class first autoloaded in a +child is fine *inside* that child and meaningless to its siblings. + +## 2. The mutation contract: what is atomic and what only looks atomic + +A PHP value slot is a 16-byte `zval`: an 8-byte payload word plus a 4-byte `u1.type_info` +word (plus `u2`). Writing one is **two stores**, and an unlocked reader can observe the two +halves from different generations: + +- **A naturally aligned 8-byte read/write never tears.** Over 2M+ unlocked reads of a pointer + slot being swapped, every observation was old-or-new, never a mix. +- **A 16-byte `zval` is not atomic.** ~1.3 % of unlocked reads saw a payload word and a type + word from different writes; at the PHP level, a three-property update was observed + half-applied in 2.7–3.8 % of unlocked reads. + +Hence the law this package implements and enforces: + +> Readers take the **same stripe lock** as the writer whenever the slot's *type* can change, +> or whenever more than one slot participates in the value being read. A single aligned +> 8-byte pointer read of a slot whose type is fixed by contract may skip the lock. + +Visibility itself is not the problem: a scalar property written in one process is visible in +the others **immediately** — no flush, no barrier, no re-attach. The worst staleness observed +under a stripe lock was ~110–210 µs, which is lock-wait and scheduler latency, not memory +visibility. + +The mirror image of that result is the reason the arena exists at all: the same object in +ordinary `malloc` memory is **copy-on-write** across a fork. A child writing `counter = 424242` +reads its own value back and the parent still sees the old one, forever, with no error +anywhere. Shared mutation without shared memory silently does nothing. + +**Critical-section discipline.** While an arena mutex is held: word loads and stores only. No +engine call that can allocate, no userland callback, no Fiber suspension, nothing that can +throw. Values are encoded before the lock and materialized after it. (This is also why an +`EOWNERDEAD` recovery can simply declare the lock consistent: a torn state is not reachable +from a critical section that is one aligned store.) + +## 3. Three fields inside a shared `zend_object` are per-process + +A `zend_object` living in shared memory has fields that describe *the process reading it* +rather than the object. Writing them into shared memory is what a naive implementation does, +and each one fails differently: + +| Field | Failure | Remedy | +|---|---|---| +| `handle` | Collides **by construction**: forked children inherit one object-store free list and hand out *identical* handle numbers for different objects. A sibling's `unregister()` then recycles the wrong slot. | per-process side table keyed by arena address; the shared field is overwritten with a sentinel so no process can trust it | +| `properties` | Written by **engine C code on read-shaped operations** — a request-heap pointer stored inside shared memory. A sibling dereferencing it segfaults (confirmed). | forced `NULL` on attach, and scrubbed after any triggering operation; the pointer is never dereferenced in shared mode | +| `ce` | Fork-stable only for classes loaded before the fork (§1). | rebound per process at attach and recorded in the side table; the shared field is advisory | + +Only `handlers` is genuinely shareable: `std_object_handlers` is one address for the whole +family. + +**The `properties` trigger list** — engine C code caches a rebuilt property bag inside the +object on all of these, none of which look like writes: + +`get_object_vars()` · `var_dump()` · `json_encode()` · `(array)` cast · `serialize()` · +`debug_zval_dump()` · `ReflectionObject` property enumeration + +A policy of "we never write it" cannot hold, because *we* are not the one writing it. The +only workable policy is: assume it will be written, force it back to `NULL` in the process +that triggered it, and never dereference what is found there. + +**`spl_object_id()` on a shared object is meaningless.** It reads `handle` out of the shared +struct. The stable cross-process identity is the **arena address** +(`PersistentStore::sharedIdOf()`); the registry keys everything by it, and nothing by handle. + +## 4. The `arData` law: engine table growth is silent corruption + +A `zend_array` grows by reallocating its bucket block (`HT_GET_DATA_ADDR`) through the +engine's allocator. For a table living in the arena that means the block moves into the +**private heap of whichever worker happened to fill it** — and the resize writes the new +private pointer into the **shared struct before it aborts**. The process that grew the table +dies with `SIGABRT`; its siblings read plausible garbage with no signal at all. + +Therefore: + +- every arena-resident table is **pre-sized** at creation and never grown; an insert past + capacity is **refused** (typed exception) rather than attempted; +- on recovery, `HT_GET_DATA_ADDR(ht) = arData - HT_HASH_SIZE(nTableMask)` is re-derived and + bounds-checked against the arena — the pointer is the only honest evidence that a resize + happened; +- `nTableMask` is declared unsigned and **used signed** (`-(2 * nTableSize)`), so it must be + sign-corrected before the multiplication or the check computes nonsense; +- growth-capable *user* collections in shared memory are purpose-built containers of + fixed-size records (`Ipc\SharedArray`), not `zend_array`s. Plain-array properties of a + shared object stay sealed immutable for the same reason. + +## 5. Robust process-shared mutexes: `EOWNERDEAD` is not an edge case + +All cross-process synchronization uses `pthread_mutex_t` placed in the arena with +`PTHREAD_PROCESS_SHARED | PTHREAD_MUTEX_ROBUST` (FFI offers no atomics or CAS, so there is no +lighter option). Verified: a mutex in `MAP_SHARED` memory really does exclude another +process, a SIGKILLed owner hands the lock to the next taker as `EOWNERDEAD` (130), and the +recovered lock is fully usable afterwards. + +Two rules, both non-negotiable: + +- **handle `EOWNERDEAD` at every lock site**, calling `pthread_mutex_consistent()` before + unlocking. Skipping it poisons that mutex arena-wide with `ENOTRECOVERABLE` (131) — + *permanently*; +- **robust is not optional**: a non-robust mutex whose owner died is eternal `EBUSY`, and a + supervisor that kills workers is a normal part of the deployment this targets. + +`sizeof(pthread_mutex_t)` is 40 bytes on x86-64 glibc; the arena measures it at runtime and +reserves a 64-byte cache line per lock, so a platform with a larger mutex is a clean typed +failure instead of overlapping neighbours. + +## 6. Memory accounting: leak-until-teardown, on purpose + +The arena is a **bump allocator**: one cursor, moved forward under a lock, never moved back. +There is no free list, no per-block header, and blocks are reclaimed only when the region +dies — which happens when the **creating process exits** and the kernel takes the mapping +back. Nothing unmaps it earlier, and that is a correctness requirement rather than laziness: +PHP runs shutdown functions *before* it destroys the symbol table and the object store, so any +variable still holding a shared object is released after an unmap armed there would have +happened — a segfault waiting for the right test order. A child never unmaps either +(`destroy()` in a child is a deliberate no-op); its copy of the mapping goes away with the +process. + +What that costs, concretely: + +- **children never free arena memory** — every reclamation path is disabled for arena-backed + state, and an attempt is a typed refusal rather than a `free()` of memory the process heap + never handed out; +- **strings are interned per write.** A mutable string property that is rewritten N times + consumes N string blocks; the old bytes leak until teardown, because a reader that took the + pointer before the swap must still be able to follow it. A workload streaming unbounded + distinct strings has to be sized for it. Scalars, object references and shared-array slots + cost nothing per write; +- **exhaustion is a normal, typed outcome** (`ArenaException::exhausted()`), not a crash. + +## 7. Closures: provenance, never inspection + +- **Pre-fork closures are safe by address** — a closure compiled before the fork barrier has + the same address and the same `op_array` in every worker. +- **Post-fork closures are unsafe in siblings.** On 8.4 a stale address segfaulted; on 8.5 — + worse — the address held a *different, perfectly valid* `Closure` and the wrong function + executed. There is no shape check that can distinguish the two cases, so closures are + rejected on **provenance** (compiled before the fork barrier) and never on inspection. +- The real blocker for arena-resident closures is not the `op_array` (a few hundred + enumerable bytes) but `run_time_cache__ptr` and `static_variables_ptr__ptr`, which point + into the **per-request** arena. Arena-resident closures would share those slots between + processes; they must be re-minted per process, by the same side-table mechanism §3 + describes for objects. + +Tracked as closure exchange in [#20]. Until then, cross-process work is passed as Task +objects (data), not as callables. + +[#20]: https://github.com/lisachenko/php-shared-data-extension/issues/20 + +## 8. How the solution is implemented, primitive by primitive + +The laws above decide the shape of everything below; this section is the map from law to +code, so a reader can go from "why is it like this" to the file that does it. + +### The arena (`Shm\Arena`, `Shm\ArenaAllocator`, `Shm\Libc`) + +One `MAP_SHARED|MAP_ANONYMOUS` mapping created before the fork, laid out as: header words +(magic, layout version, size, bump cursor, creator pid, measured mutex size, roots capacity) · +a bank of 64 robust process-shared mutexes on 64-byte cache lines (slot 0 = allocator, +slot 1 = roots directory, 2… = consumer stripes) · a roots directory of 64 named addresses · +the bump-allocated payload. + +- `allocate()` moves the shared cursor under the allocator mutex — that is the entire + allocator, per §6, and it is why an allocation from a child is safe while a free is not; +- `sizeof(pthread_mutex_t)` is *measured* at runtime rather than assumed, and checked against + the 64-byte slot stride; +- `stripeFor($address)` hashes an address onto one of the 62 consumer stripes, which is how an + unbounded number of small structures share a bounded bank of locks; +- **no public method returns `FFI\CData`**: views are bound once per process at map time, and + callers see integers and strings. That is not tidiness — it is what keeps every critical + section to aligned word access (§2); +- `ArenaAllocator` implements z-engine's `Allocator` seam, so the *persister* mints object + clones, snapshots, strings, sealed arrays **and their bucket keys** out of the arena. There + is no half-way: one malloc-backed block inside a shared graph is a pointer a sibling cannot + follow. + +### The registry (`Registry`, `Shm\ArenaRegistryLayout`) + +Named graphs and a process-wide object table, both living in the arena and published in the +roots directory — a forked child finds them with nothing but the mapping. Tables are pre-sized +and never grown (§4): an insert that would resize is refused with the table named, and +recovery re-derives `HT_GET_DATA_ADDR` and bounds-checks it against the arena. Object records +carry the object's **role** (frozen or shared-mutable), so every worker reads the same +lifecycle rules for the same address. + +### The per-process side table (`SideTable`, `PersistentStore`) + +The remedy for §3, keyed by arena address: `handle` (from z-engine's +`ObjectEntry::register()`, after which the shared field is overwritten with a sentinel), `ce` +(rebound per process at attach), `properties` (forced `NULL` at attach and scrubbed after any +triggering operation, never dereferenced). Identity is exposed as +`PersistentStore::sharedIdOf()` — the arena address — and never as a handle. + +### Mutation (`SharedObjectHandle`) + +The synchronized write path for a graph persisted with `mutable: true`. Every write validates +and encodes its payload *before* taking the object's stripe lock; the critical section is +payload word then type word and nothing else (§2). Strings are interned into the arena and +swapped as one aligned 8-byte pointer, the previous block leaking by design (§6); object +references may only point at another object of the same arena; array slots stay sealed. +Direct `$obj->prop = …` writes remain legal, work for scalars and are **unsynchronized** — +the engine gives no write hook to a class rewired to `std_object_handlers`, which is a +deliberate trade, not an oversight. A slot found holding a foreign (non-arena) pointer at +request end is repaired from the frozen image rather than left for a sibling to dereference. + +### Value records and the IPC primitives (`Ipc\*`) + +Everything crossing a worker boundary is a **16-byte tagged record** — `uint8 tag | 7 pad | +uint64 payload` — where the payload is the value itself for scalars and an *address* for +strings, objects and shared arrays. A value with no address-shaped form (plain array, +resource, non-shared object, closure) is refused with the remedy named, never encoded: that +is the Never-Serialize Rule in one sentence. + +- `SharedChannel` — a ring of records plus sender/receiver waiter tables under its **own** + dedicated mutex (a structure locked on every operation does not belong on a shared stripe). + Head and tail are monotonic counters, so fill level is a subtraction; capacity 0 is a true + cross-process rendezvous; `close()` crosses processes; +- `SharedArray` — fixed-capacity vector of records, per-instance stripe: the container a + `zend_array` cannot be (§4); +- `ResultSlotTable` — futures. A slot settles exactly once, carrying either a value record or + a `SharedError` (a persisted three-string object; a `Throwable` can never be shared); +- `SharedMutex` / `AtomicInt` / `SharedWaitGroup` — robust locking, an aligned word with + stripe-locked read-modify-write (FFI has no CAS), and a counter with waiters; +- `WakeRegistry` — one inherited socket pair per process. Sockets carry a fixed 16-byte event + record (`opcode | tag | id | address`) and never a payload: **signalling, not + serialization**. Waking is level-triggered and re-checked inside the critical section, so a + wakeup may be spurious but can never be lost. + +## 9. Structural notes worth knowing + +- `zval` (16), `Bucket` (32), `zend_array` (56) and `zend_object` (56 + 16·(n−1)) are + **byte-identical between 8.4 and 8.5** on linux-x64-nts, so the arena layout needs no + per-minor versioning beyond its own `LAYOUT_VERSION`. Field *offsets* are still read + through z-engine, which versions them per minor. +- An engine-formatted `zend_object` placed in shared memory attaches as an ordinary PHP + instance in several processes at once — property reads and writes go through the normal + engine paths, with no handler tricks. +- The reverse direction works too: a child can bump-allocate and persist a brand-new object + *after* the fork and hand its address to the parent, which attaches it after the child has + exited. + +## 10. Known limitations, and where they are tracked + +| Limitation | Status | +|---|---| +| Classes must be loaded before the fork | by design (§1); documented on `PersistentStore::bootShared()` | +| `spl_object_id()` is not an identity in shared mode | by design; use `PersistentStore::sharedIdOf()` (§3) | +| Plain-array properties of shared objects are immutable | by design (§4); mutable collections are `Ipc\SharedArray` | +| Arena memory is never reclaimed per block | v1 accounting (§6); a shared free list needs cross-process reachability data that nothing here can produce yet | +| String rewrites leak the previous bytes | consequence of §6; a content-keyed persistent intern table is the next iteration | +| Direct `$obj->prop = ...` writes are unsynchronized | by design: the extension rewires shared objects to `std_object_handlers`, so there is no write hook. Scalar writes are visible but racy; a string/array/object written that way stores a request-heap pointer and is restored from the persisted image at detach. The synchronized path is `PersistentStore::mutableHandle()` | +| The shared `handle` field is not the sentinel for a few instructions while a process detaches | inherent: recycling an object-store slot is an engine call and engine calls cannot run under an arena mutex. Nothing in the package reads that field — identity is `sharedIdOf()` (§3) | +| Post-fork closures cannot be shared | [#20] (§7) | +| A borrowed `PersistentHashTable` view cannot re-adopt the external storage block it sits on, so the growth guard is re-derived in this package | z-engine seam follow-up; see the `TODO` in `Registry::assertRegistryRoom()` and [z-engine#223](https://github.com/lisachenko/z-engine/pull/223) | + +--- + +*Evidence: the [spike-gate record on #15][gate] (S8, S12–S17, both minors). The repository +keeps two self-contained spikes — `spikes/s8-robust-pshared-mutex.php` and +`spikes/s15-concurrent-bump-allocation.php` — which run against this package's own `Arena` +from the repository root through Composer's autoloader.* diff --git a/spikes/README.md b/spikes/README.md new file mode 100644 index 0000000..e4b7021 --- /dev/null +++ b/spikes/README.md @@ -0,0 +1,59 @@ +# Spikes + +Throwaway-by-intent programs, kept because their *answers* are load-bearing. Everything in +the fork-shared arena rests on behaviour that no documentation guarantees — whether a +`pthread_mutex_t` in `MAP_SHARED` memory really excludes another process, what the engine +does when it grows a hashtable that lives in somebody else's memory, whether a `zval` write +in one process is visible in another. These files are how those questions were answered. + +They are not part of the test suite: they fork, kill and SIGABRT on purpose, and several of +them are supposed to crash. Run them by hand, from the repository root; they bootstrap +through Composer's autoloader and nothing else. + +```bash +php8.4 -d ffi.enable=1 -d opcache.jit=off spikes/s8-robust-pshared-mutex.php +php8.4 -d ffi.enable=1 -d opcache.jit=off spikes/s15-concurrent-bump-allocation.php +``` + +## Arena spikes (E1) + +| File | Question | Verdict | +|---|---|---| +| `s8-robust-pshared-mutex.php` | Do the arena's `PTHREAD_PROCESS_SHARED` + `PTHREAD_MUTEX_ROBUST` mutexes exclude another process, survive a SIGKILLed owner (`EOWNERDEAD` → `pthread_mutex_consistent`) and stay usable afterwards? | GREEN — all three | +| `s15-concurrent-bump-allocation.php` | Four children, 8000 blocks through one shared bump cursor: any overlap, any block written through by a foreign process? | GREEN — zero overlaps, zero foreign markers | + +Both run against this package's own `Arena`, so they double as end-to-end checks of the +class the rest of the epic builds on. + +## The wider validation sweep (not in this repository) + +The sweep that established the premise of EPIC #15 (S12–S17) ran on PHP 8.4 **and** 8.5 +before this package had an arena of its own, so it carried its own bootstrap and resolved +z-engine from outside the repository — nothing that can be run from a checkout, and nothing +any later work needs as context. Its verdicts are recorded where they belong, on the ticket: +[the spike gate on #15](https://github.com/lisachenko/php-shared-data-extension/issues/15#issuecomment-5303807403). +The findings that bind the implementation, restated here so the code has something to cite: + +- **S12** — an engine-formatted `zend_object` in arena memory attaches as an ordinary PHP + instance in several processes at once, and scalar property writes are visible immediately + (≈200 µs worst observed staleness). A 16-byte `zval` is **not** atomic: value word and + type word tore apart in ~1.3 % of unlocked reads, so readers take the stripe lock whenever + a type can change or several slots participate. A naturally aligned 8-byte pointer read + never tore. +- **S13** — a hashtable the engine grows in shared memory fails *twice*: `SIGABRT` in the + process that grew it, and — worse — the new private-heap `arData` is written into the + shared struct **before** the abort, so surviving siblings read plausible garbage with no + signal. This is why registry tables are pre-sized, why an insert past capacity is refused + rather than attempted, and why `Registry` re-derives `HT_GET_DATA_ADDR` and checks it + against the arena bounds on recovery. +- **S14** — `handle`, `properties` and (for classes loaded after the fork) `ce` are + per-process fields sitting inside the shared object. Forked children even hand out + *identical* handle numbers, which is why the registry keys everything by arena address and + never by handle. Moving those fields into a per-process side table is E2's job (#17); until + then arena mode carries the limitations listed in `PersistentStore::bootShared()`. +- **S16/S17** — arena-interned strings swap safely under a pointer store; closures are only + fork-safe when they existed before the fork (E5). + +The claims that this package depends on are not left resting on that sweep: S12/S14/S16 are +promoted to real tests in `tests/Shm/` (mutation visibility, per-process side table, unlocked +pointer reads), which is where they are re-run on both minors on every change. diff --git a/spikes/s15-concurrent-bump-allocation.php b/spikes/s15-concurrent-bump-allocation.php new file mode 100644 index 0000000..12c9480 --- /dev/null +++ b/spikes/s15-concurrent-bump-allocation.php @@ -0,0 +1,123 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +/** + * S15 - can four forked children hammer the bump allocator without ever overlapping? + * + * The arena cursor is one 64-bit word in shared memory. Four processes moving it at the + * same time is exactly the situation where a missing lock does NOT crash: it silently + * hands two workers the same address, and the corruption surfaces much later as a wrong + * value in somebody else's object. So the spike checks the property directly. + * + * Each child allocates BLOCKS_PER_CHILD blocks of a size that varies per iteration, + * stamps every byte of each block with its own marker byte, and reports the blocks back + * to the parent over a pipe as fixed-size binary records (address + size, 16 bytes - no + * serialization). The parent then verifies: + * + * - no two blocks from any two children overlap; + * - every block still carries exactly the marker of the child that owns it, so nobody + * wrote through anybody else's block; + * - the arena watermark accounts for at least the sum of all block sizes. + * + * Run: php -d ffi.enable=1 spikes/s15-concurrent-bump-allocation.php + */ + +use Lisachenko\SharedData\Shm\Arena; + +require __DIR__ . '/../vendor/autoload.php'; + +const CHILDREN = 4; +const BLOCKS_PER_CHILD = 2000; + +$arena = Arena::create(8 << 20); + +/** @var array $children pid => [read end, marker] */ +$children = []; + +for ($index = 0; $index < CHILDREN; $index++) { + $pipe = []; + if (!socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $pipe)) { + fwrite(STDERR, "cannot create a socket pair\n"); + + exit(1); + } + + $pid = pcntl_fork(); + if ($pid === 0) { + socket_close($pipe[0]); + $marker = 0x41 + $index; + $records = ''; + for ($block = 0; $block < BLOCKS_PER_CHILD; $block++) { + $size = 8 + ($block % 57); + $address = $arena->allocate($size, 8); + $arena->writeBytes($address, str_repeat(\chr($marker), $size)); + $records .= pack('PP', $address, $size); + } + socket_write($pipe[1], $records); + socket_close($pipe[1]); + + exit(0); + } + + socket_close($pipe[1]); + $children[$pid] = [$pipe[0], 0x41 + $index]; +} + +/** @var list $blocks address, size, marker */ +$blocks = []; +foreach ($children as $pid => [$socket, $marker]) { + $payload = ''; + while (($chunk = socket_read($socket, 65536, PHP_BINARY_READ)) !== false && $chunk !== '') { + $payload .= $chunk; + } + socket_close($socket); + pcntl_waitpid($pid, $status); + + for ($offset = 0; $offset < \strlen($payload); $offset += 16) { + /** @var array{1: int, 2: int} $record */ + $record = unpack('Paddress/Psize', substr($payload, $offset, 16)); + $blocks[] = [$record['address'], $record['size'], $marker]; + } +} + +printf("blocks reported: %d (expected %d)\n", \count($blocks), CHILDREN * BLOCKS_PER_CHILD); + +usort($blocks, static fn (array $left, array $right): int => $left[0] <=> $right[0]); + +$overlaps = 0; +$corrupted = 0; +$totalBytes = 0; +$previousEnd = 0; +foreach ($blocks as [$address, $size, $marker]) { + $totalBytes += $size; + if ($address < $previousEnd) { + $overlaps++; + } + $previousEnd = $address + $size; + if ($arena->readBytes($address, $size) !== str_repeat(\chr($marker), $size)) { + $corrupted++; + } +} + +printf("overlapping blocks: %d\n", $overlaps); +printf("blocks with a foreign marker: %d\n", $corrupted); +printf("bytes requested: %d, arena watermark: %d\n", $totalBytes, $arena->watermark()); + +$failed = \count($blocks) !== CHILDREN * BLOCKS_PER_CHILD + || $overlaps !== 0 + || $corrupted !== 0 + || $arena->watermark() < $totalBytes; + +printf("\nS15 verdict: %s\n", $failed ? 'FAILED' : 'concurrent bump allocation is disjoint and intact'); + +exit($failed ? 1 : 0); diff --git a/spikes/s8-robust-pshared-mutex.php b/spikes/s8-robust-pshared-mutex.php new file mode 100644 index 0000000..3ff0809 --- /dev/null +++ b/spikes/s8-robust-pshared-mutex.php @@ -0,0 +1,104 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +/** + * S8 - are the arena's process-shared ROBUST mutexes actually correct? + * + * Three questions, in order of how badly a wrong answer would hurt: + * + * 1. Does a mutex placed in MAP_SHARED memory and initialized with + * PTHREAD_PROCESS_SHARED exclude a DIFFERENT PROCESS at all? (If pshared were + * ignored, every arena critical section would be decorative.) + * 2. Does PTHREAD_MUTEX_ROBUST really hand the lock to the next taker with EOWNERDEAD + * when the owner is killed mid-section, instead of wedging the whole worker pool + * forever? (This is the entire reason a worker may be SIGKILLed by a supervisor.) + * 3. Is the recovered lock usable afterwards - can the survivor lock/unlock it normally + * once it has been declared consistent? + * + * Run: php -d ffi.enable=1 spikes/s8-robust-pshared-mutex.php + */ + +use Lisachenko\SharedData\Shm\Arena; + +require __DIR__ . '/../vendor/autoload.php'; + +$arena = Arena::create(1 << 20); +$flag = $arena->allocate(8); +$arena->writeWord($flag, 0); + +$verdicts = []; + +// --- 1. mutual exclusion across processes ----------------------------------------------- +// The child takes stripe 2 and holds it for 300 ms while the parent tries to take it. +$arena->lockStripe(2); + +$child = pcntl_fork(); +if ($child === 0) { + // Announce that the child is alive, then block until the parent releases the stripe + $arena->writeWord($flag, 1); + $arena->lockStripe(2); + $arena->writeWord($flag, 2); + usleep(200_000); + $arena->unlockStripe(2); + + exit(0); +} + +while ($arena->readWord($flag) === 0) { + usleep(1_000); +} +usleep(50_000); +// The child is past its "I am alive" write and inside lockStripe(): still 1, not 2 +$verdicts['child blocked while the parent held the stripe'] = $arena->readWord($flag) === 1; + +$arena->unlockStripe(2); +pcntl_waitpid($child, $status); +$verdicts['child took the stripe once it was released'] = $arena->readWord($flag) === 2; + +// --- 2. owner-died recovery ------------------------------------------------------------- +// The child takes stripe 4, tells the parent, and is SIGKILLed while still holding it. +$arena->writeWord($flag, 0); + +$victim = pcntl_fork(); +if ($victim === 0) { + $arena->lockStripe(4); + $arena->writeWord($flag, 1); + sleep(30); // killed long before this returns + + exit(0); +} + +while ($arena->readWord($flag) !== 1) { + usleep(1_000); +} +posix_kill($victim, SIGKILL); +pcntl_waitpid($victim, $status); + +$recovered = $arena->lockStripe(4); +$verdicts['killed owner is reported as EOWNERDEAD to the next locker'] = $recovered; + +// --- 3. the recovered lock still works -------------------------------------------------- +$arena->unlockStripe(4); +$again = $arena->lockStripe(4); +$arena->unlockStripe(4); +$verdicts['recovered mutex behaves normally afterwards'] = $again === false; + +$failed = false; +foreach ($verdicts as $question => $answer) { + $failed = $failed || !$answer; + printf("%-58s %s\n", $question, $answer ? 'YES' : 'NO'); +} + +printf("\nS8 verdict: %s\n", $failed ? 'FAILED' : 'robust pshared mutexes behave as the arena assumes'); + +exit($failed ? 1 : 0); diff --git a/src/Ipc/AtomicInt.php b/src/Ipc/AtomicInt.php new file mode 100644 index 0000000..51255b8 --- /dev/null +++ b/src/Ipc/AtomicInt.php @@ -0,0 +1,148 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; + +/** + * One shared 64-bit cell, read and written by every process of the family + * + * "Atomic" here is a statement about the ENGINE, not about the CPU: FFI exposes no atomics, + * no CAS and no fences, so the only true hardware guarantee available is that an ALIGNED + * 8-byte load or store never tears - measured over two million unlocked reads while another + * process wrote the same word (EPIC #15, correction #2). get() and set() ride exactly that + * guarantee and take no lock at all. + * + * Read-modify-write is a different question: add() and compareAndSet() would need a real + * CAS instruction, so they take a stripe mutex from the arena bank instead (chosen by the + * cell's address, so unrelated counters rarely contend). That makes them correct and roughly + * a lock's worth of cost - fine for wait-group counters and statistics, wrong for a hot inner + * loop, which is what the honest name for v1 would be "mutex-backed atomics". + */ +final class AtomicInt +{ + private readonly int $stripe; + + private bool $recoveredLock = false; + + private function __construct( + private readonly Arena $arena, + private readonly int $address, + ) { + $this->stripe = $arena->stripeFor($address); + } + + /** + * Allocates a cell in the arena, optionally publishing it under a name + */ + public static function create(Arena $arena, int $initial = 0, ?string $name = null): self + { + $address = $arena->allocate(8, 8); + $arena->writeWord($address, $initial); + + if ($name !== null) { + $arena->putRoot($name, $address); + } + + return new self($arena, $address); + } + + /** + * Binds a cell another process created, by address + */ + public static function attach(Arena $arena, int $address): self + { + if (!$arena->contains($address, 8)) { + throw IpcException::notShared('atomic cell', $address); + } + + return new self($arena, $address); + } + + /** + * Binds a cell published in the arena roots directory + */ + public static function open(Arena $arena, string $name): self + { + return self::attach($arena, $arena->requireRoot($name)); + } + + public function address(): int + { + return $this->address; + } + + /** + * Plain aligned load - old value or new value, never a mixture + */ + public function get(): int + { + return $this->arena->readWord($this->address); + } + + /** + * Plain aligned store + */ + public function set(int $value): void + { + $this->arena->writeWord($this->address, $value); + } + + /** + * Adds $delta and returns the new value, serialized against every other process + */ + public function add(int $delta): int + { + $recovered = $this->arena->lockStripe($this->stripe); + + $value = $this->arena->readWord($this->address) + $delta; + $this->arena->writeWord($this->address, $value); + + $this->arena->unlockStripe($this->stripe); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + return $value; + } + + /** + * Sets the cell to $new if and only if it currently holds $expected + */ + public function compareAndSet(int $expected, int $new): bool + { + $recovered = $this->arena->lockStripe($this->stripe); + + $matched = $this->arena->readWord($this->address) === $expected; + if ($matched) { + $this->arena->writeWord($this->address, $new); + } + + $this->arena->unlockStripe($this->stripe); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + return $matched; + } + + /** + * Whether a stripe guarding this cell was ever recovered from a died owner + * + * The guarded state is a single word, so a lock inherited through EOWNERDEAD protects + * something that cannot be half-written: recovery is reported, not repaired. + */ + public function wasLockRecovered(): bool + { + return $this->recoveredLock; + } +} diff --git a/src/Ipc/ClosedChannelException.php b/src/Ipc/ClosedChannelException.php new file mode 100644 index 0000000..00cb1f6 --- /dev/null +++ b/src/Ipc/ClosedChannelException.php @@ -0,0 +1,45 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +/** + * A value was sent into a channel that somebody - possibly another process - had closed + * + * Closing is a property of the SHARED channel, not of a process's view of it: the flag lives + * in the arena, so a producer in one worker learns about a consumer's close() the moment it + * next takes the ring lock. Receiving from a closed channel is never an error - receivers + * drain what is still buffered and then see the end of stream - but sending into one is, + * because the value would have no reader. + */ +final class ClosedChannelException extends \RuntimeException +{ + public static function onSend(int $address): self + { + return new self(sprintf( + 'The channel at 0x%x is closed; nothing can be sent into it anymore. Closing is shared ' . + 'state - another worker may have closed it - and receivers may still drain the records ' . + 'already in the ring.', + $address, + )); + } + + public static function whileParked(int $address): self + { + return new self(sprintf( + 'The channel at 0x%x was closed while this send was waiting for a receiver to take the ' . + 'record; the handoff will not complete.', + $address, + )); + } +} diff --git a/src/Ipc/IpcException.php b/src/Ipc/IpcException.php new file mode 100644 index 0000000..d50b56c --- /dev/null +++ b/src/Ipc/IpcException.php @@ -0,0 +1,103 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +/** + * Structural failures of the shared IPC primitives + * + * These are the fixed-capacity limits the shared area is built out of: a wake registry with + * a slot per process, a slot table sized before the fork, waiter tables sized per structure. + * Nothing here grows on demand - growing a shared structure means reallocating it, and a + * reallocation would move it into one worker's private heap - so hitting a limit is a + * typed, actionable failure that names the knob instead of silent corruption. + */ +final class IpcException extends \RuntimeException +{ + public static function wakeRegistryFull(int $capacity): self + { + return new self(sprintf( + 'Every one of the %d wake slots is claimed: this worker family has more processes than ' . + 'the notification plane was created for. Size it with WakeRegistry::create($arena, $slots) ' . + 'BEFORE forking - the socket pairs must exist at fork time to be inherited.', + $capacity, + )); + } + + public static function wakeRegistryNotInherited(): self + { + return new self( + 'This wake registry has no socket pair for the current process. A registry must be created ' . + 'before any worker forks, so every process inherits every pair; a process that was not ' . + 'forked from the creator cannot be notified (descriptors are per-process, and the arena ' . + 'carries addresses, not file handles).', + ); + } + + public static function slotTableFull(int $capacity): self + { + return new self(sprintf( + 'All %d result slots are used. The slot table is pre-sized in the arena and never grows; ' . + 'create it with a larger capacity before the workers fork.', + $capacity, + )); + } + + public static function unknownSlot(int $id, int $capacity): self + { + return new self(sprintf('Result slot %d does not exist; the table holds slots 0..%d', $id, $capacity - 1)); + } + + public static function slotAlreadyCompleted(int $id): self + { + return new self(sprintf( + 'Result slot %d is already completed. A slot is written exactly once - allocate a new one ' . + 'for the next result rather than reusing a settled slot.', + $id, + )); + } + + public static function invalidCapacity(string $structure, int $capacity): self + { + return new self(sprintf('%s capacity must be a positive number of records, got %d', $structure, $capacity)); + } + + public static function outOfRange(int $index, int $capacity): self + { + return new self(sprintf( + 'Index %d is outside the shared array; it holds %d fixed slots (0..%d) and cannot grow', + $index, + $capacity, + $capacity - 1, + )); + } + + public static function negativeCounter(int $value): self + { + return new self(sprintf( + 'A wait group counter went negative (%d): done() was called more often than add(). The ' . + 'counter is shared, so the miscount belongs to the worker family, not to one process.', + $value, + )); + } + + public static function notShared(string $structure, int $address): self + { + return new self(sprintf( + 'The %s at 0x%x is not inside the arena; only structures every process maps at the same ' . + 'address can be attached', + $structure, + $address, + )); + } +} diff --git a/src/Ipc/NotShareableValueException.php b/src/Ipc/NotShareableValueException.php new file mode 100644 index 0000000..ba9923d --- /dev/null +++ b/src/Ipc/NotShareableValueException.php @@ -0,0 +1,102 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\PersistentStore; + +/** + * A value was handed to the shared area that cannot cross a worker boundary + * + * Every message here names the REMEDY, because the alternative to a shareable value is + * never "give up": a plain array becomes a SharedArray, an ordinary object becomes a shared + * one through PersistentStore::persist(), a closure becomes a Task object. The one thing + * that is never offered is serialization - encoding the value into bytes is precisely what + * this package exists to avoid, so a value that cannot travel by address does not travel. + */ +final class NotShareableValueException extends \InvalidArgumentException +{ + public static function plainArray(): self + { + return new self(sprintf( + 'A plain PHP array cannot be shared: its bucket storage is grown by the engine into the ' . + 'private heap of whichever worker writes to it, so siblings would follow a pointer into ' . + 'foreign memory. Copy the elements into a %s of fixed capacity and share that instead.', + SharedArray::class, + )); + } + + public static function closure(): self + { + return new self( + 'A Closure cannot be shared. Sharing one by address is only safe when the function was ' . + 'compiled BEFORE the fork barrier, and that provenance cannot be recovered from the object ' . + 'itself: spike S17 observed a post-fork closure address that held a different, perfectly ' . + 'valid Closure and executed the wrong function. Until closure exchange lands ' . + '(lisachenko/php-shared-data-extension#20), pass a shared Task object naming the work ' . + 'instead of the callable that performs it.', + ); + } + + public static function resource(): self + { + return new self( + 'A resource cannot be shared: it is an index into a per-process table of engine state ' . + '(file handles, sockets, contexts) and means nothing in another worker. Open the resource ' . + 'in the worker that uses it, or hand over the descriptor with the notification socket.', + ); + } + + public static function foreignObject(string $className): self + { + return new self(sprintf( + 'An instance of %s is an ordinary request object and cannot be shared: its zend_object ' . + 'lives in this worker\'s private heap. Move it into the arena first with ' . + '%s::persist(%s::class, $object) and share the instance that call returns - it is the ' . + 'canonical shared one, and its address means the same thing in every process of the family.', + $className, + PersistentStore::class, + $className, + )); + } + + public static function withoutStore(string $className): self + { + return new self(sprintf( + 'An instance of %s cannot be shared through a codec that has no %s: object records carry ' . + 'the address of a shared object, and only the store\'s registry can tell whether an ' . + 'address is one of ours. Build the codec with the arena-backed store that persisted the ' . + 'object (PersistentStore::bootShared()).', + $className, + PersistentStore::class, + )); + } + + public static function unsupportedType(string $type): self + { + return new self(sprintf( + 'Values of type %s cannot cross a worker boundary; the shared area carries null, bool, ' . + 'int, float, arena strings, shared objects and shared arrays.', + $type, + )); + } + + public static function foreignAddress(int $address): self + { + return new self(sprintf( + 'Address 0x%x is not inside this arena, so it cannot be part of a value record; a record ' . + 'may only reference memory every process of the family maps at the same address.', + $address, + )); + } +} diff --git a/src/Ipc/ResultSlotTable.php b/src/Ipc/ResultSlotTable.php new file mode 100644 index 0000000..5d32d29 --- /dev/null +++ b/src/Ipc/ResultSlotTable.php @@ -0,0 +1,353 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; +use Lisachenko\SharedData\Shm\ArenaAllocator; + +/** + * Futures over the shared area: a table of slots a coroutine's return value lands in + * + * This is the piece the runtime model of EPIC #15 is built on. A coroutine finishing + * ANYWHERE in the process tree writes its return value into its slot as a 16-byte record, + * and the process waiting for it - very likely a different one - is told over its + * notification socket with a fixed event record `{RESULT, slot id, tag, address}`. It then + * reads the value straight out of shared memory. The socket never carries the value: for an + * INT or FLOAT the event's address field is zero, and for a string, object or shared array + * it holds an ADDRESS every process maps identically. + * + * ```text + * header (4 words) capacity | next slot | mutex address | reserved + * slot (8 words) state | tag | payload | waiters parked | waiter table (4 entries) + * ``` + * + * A slot settles exactly once: complete() and completePanic() refuse a slot that is already + * DONE or PANIC, which is what makes "read the record after seeing the state" a safe + * sequence rather than a race with a second writer. Slots are handed out by a bump counter + * and never recycled in v1 - the table is pre-sized before the fork, like everything else in + * the arena, and exhausting it is a typed failure that names the knob. + * + * The same machinery carries spawn ARGUMENTS in the opposite direction: a slot completed by + * the spawning process before the worker looks at it is a one-shot value handed downwards + * with the identical tag contract. + */ +final class ResultSlotTable +{ + public const string DEFAULT_ROOT = 'ipc.results'; + + /** + * Waiters per slot; a future normally has one owner, four is room for a select() fan-out + */ + public const int SLOT_WAITERS = 4; + + private const float WAIT_SLICE = 0.05; + + private const int WORD_CAPACITY = 0; + private const int WORD_NEXT = 1; + private const int WORD_MUTEX = 2; + private const int HEADER_WORDS = 4; + + private const int SLOT_WORD_STATE = 0; + private const int SLOT_WORD_TAG = 1; + private const int SLOT_WORD_PAYLOAD = 2; + private const int SLOT_WORD_PARKED = 3; + private const int SLOT_WORD_WAITERS = 4; + private const int SLOT_WORDS = 8; + + private readonly Arena $arena; + + private readonly int $capacity; + + private readonly int $mutex; + + private bool $recoveredLock = false; + + private function __construct( + private readonly ArenaAllocator $allocator, + private readonly ValueCodec $codec, + private readonly WakeRegistry $wake, + private readonly int $address, + ) { + $this->arena = $allocator->arena(); + if (!$this->arena->contains($address, self::HEADER_WORDS * 8)) { + throw IpcException::notShared('result slot table', $address); + } + $this->capacity = $this->arena->readWord($address + self::WORD_CAPACITY * 8); + $this->mutex = $this->arena->readWord($address + self::WORD_MUTEX * 8); + } + + /** + * Creates the table in the arena; do this before the workers fork + */ + public static function create( + ArenaAllocator $allocator, + ValueCodec $codec, + WakeRegistry $wake, + int $capacity, + ?string $name = self::DEFAULT_ROOT, + ): self { + if ($capacity <= 0) { + throw IpcException::invalidCapacity('Result slot table', $capacity); + } + $arena = $allocator->arena(); + $address = $arena->allocate((self::HEADER_WORDS + $capacity * self::SLOT_WORDS) * 8, 64); + $mutex = $arena->allocateMutex(); + + $arena->writeWord($address + self::WORD_CAPACITY * 8, $capacity); + $arena->writeWord($address + self::WORD_MUTEX * 8, $mutex); + + if ($name !== null) { + $arena->putRoot($name, $address); + } + + return new self($allocator, $codec, $wake, $address); + } + + public static function attach( + ArenaAllocator $allocator, + ValueCodec $codec, + WakeRegistry $wake, + int $address, + ): self { + return new self($allocator, $codec, $wake, $address); + } + + /** + * Binds a table published in the arena roots directory + */ + public static function open( + ArenaAllocator $allocator, + ValueCodec $codec, + WakeRegistry $wake, + string $name = self::DEFAULT_ROOT, + ): self { + return new self($allocator, $codec, $wake, $allocator->arena()->requireRoot($name)); + } + + public function address(): int + { + return $this->address; + } + + public function capacity(): int + { + return $this->capacity; + } + + /** + * The descriptor a scheduler parks on while awaiting slots + * + * @return resource + */ + public function notificationStream() + { + return $this->wake->stream(); + } + + public function wasLockRecovered(): bool + { + return $this->recoveredLock; + } + + /** + * Takes the next free slot; the id is what travels to whoever will complete it + */ + public function allocateSlot(): int + { + $recovered = $this->arena->lockMutexAt($this->mutex); + + $next = $this->arena->readWord($this->address + self::WORD_NEXT * 8); + $fits = $next < $this->capacity; + if ($fits) { + $this->arena->writeWord($this->address + self::WORD_NEXT * 8, $next + 1); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if (!$fits) { + throw IpcException::slotTableFull($this->capacity); + } + + return $next; + } + + /** + * Settles a slot with a value and wakes whoever is awaiting it + */ + public function complete(int $id, mixed $value): void + { + // Encoded before the lock: interning a string allocates arena memory and a + // non-shareable value must throw without a lock ever being taken + [$tag, $payload] = $this->codec->encode($value); + + $this->settle($id, ResultState::Done, $tag, $payload, WakeOpcode::Result); + } + + /** + * Settles a slot with the address of a shared error-info object (see SharedError) + */ + public function completePanic(int $id, int $errorAddress): void + { + if (!$this->arena->contains($errorAddress, 8)) { + throw NotShareableValueException::foreignAddress($errorAddress); + } + + $this->settle($id, ResultState::Panic, ValueTag::Obj, $errorAddress, WakeOpcode::Panic); + } + + /** + * Reads a slot as it stands right now, without waiting + */ + public function readSlot(int $id): SlotResult + { + $slot = $this->slotAddress($id); + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $state = $this->arena->readWord($slot + self::SLOT_WORD_STATE * 8); + $tag = $this->arena->readWord($slot + self::SLOT_WORD_TAG * 8); + $payload = $this->arena->readWord($slot + self::SLOT_WORD_PAYLOAD * 8); + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + $state = ResultState::from($state); + if ($state === ResultState::Pending) { + return new SlotResult($id, $state); + } + $valueTag = ValueTag::from($tag); + + // Materialized outside the lock: attaching an object registers it in this request's + // object store, which is an engine call that allocates + return new SlotResult($id, $state, $this->codec->decode($valueTag, $payload), $valueTag); + } + + /** + * Parks until a slot is settled (or the deadline passes, which returns it still pending) + * + * @param float|null $timeout Seconds to wait; null waits forever + */ + public function await(int $id, ?float $timeout = null): SlotResult + { + $slot = $this->slotAddress($id); + $deadline = $timeout === null ? null : microtime(true) + $timeout; + $wakeSlot = $this->wake->slot(); + $waiters = new WaiterTable($this->arena, $slot + self::SLOT_WORD_WAITERS * 8, self::SLOT_WAITERS); + + while (true) { + $result = $this->readSlot($id); + if (!$result->isPending()) { + return $result; + } + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $settled = $this->arena->readWord($slot + self::SLOT_WORD_STATE * 8) !== ResultState::Pending->value; + $entry = null; + if (!$settled) { + // Registered and re-checked under ONE lock: a completion after this point + // necessarily sees the entry, so no wakeup can be lost + $entry = $waiters->register($wakeSlot); + $this->bumpParked($slot, 1); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if (!$settled) { + if ($deadline === null) { + $this->wake->wait(self::WAIT_SLICE); + } else { + $remaining = $deadline - microtime(true); + if ($remaining > 0) { + $this->wake->wait(min($remaining, self::WAIT_SLICE)); + } + } + $this->unpark($slot, $waiters, $entry); + } + + if ($deadline !== null && microtime(true) >= $deadline) { + return $this->readSlot($id); + } + } + } + + /** + * Writes the record, flips the state and notifies the parked waiters + */ + private function settle(int $id, ResultState $state, ValueTag $tag, int $payload, WakeOpcode $opcode): void + { + $slot = $this->slotAddress($id); + $waiters = new WaiterTable($this->arena, $slot + self::SLOT_WORD_WAITERS * 8, self::SLOT_WAITERS); + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $settled = $this->arena->readWord($slot + self::SLOT_WORD_STATE * 8) !== ResultState::Pending->value; + if (!$settled) { + $this->arena->writeWord($slot + self::SLOT_WORD_TAG * 8, $tag->value); + $this->arena->writeWord($slot + self::SLOT_WORD_PAYLOAD * 8, $payload); + // State last: a reader that sees a settled state is guaranteed to see the + // record that goes with it, and readers take this same lock anyway + $this->arena->writeWord($slot + self::SLOT_WORD_STATE * 8, $state->value); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if ($settled) { + throw IpcException::slotAlreadyCompleted($id); + } + + $this->wake->notifyAll($waiters->occupants(), WakeEvent::forValue($opcode, $id, $tag, $payload)); + } + + private function unpark(int $slot, WaiterTable $waiters, ?int $entry): void + { + if ($entry === null) { + return; + } + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $waiters->release($entry); + $this->bumpParked($slot, -1); + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + } + + /** + * Moves a slot's parked counter; the caller holds the table lock + */ + private function bumpParked(int $slot, int $delta): void + { + $parked = $this->arena->readWord($slot + self::SLOT_WORD_PARKED * 8) + $delta; + $this->arena->writeWord($slot + self::SLOT_WORD_PARKED * 8, max($parked, 0)); + } + + private function slotAddress(int $id): int + { + if ($id < 0 || $id >= $this->capacity) { + throw IpcException::unknownSlot($id, $this->capacity); + } + + return $this->address + (self::HEADER_WORDS + $id * self::SLOT_WORDS) * 8; + } +} diff --git a/src/Ipc/ResultState.php b/src/Ipc/ResultState.php new file mode 100644 index 0000000..beca855 --- /dev/null +++ b/src/Ipc/ResultState.php @@ -0,0 +1,36 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +/** + * The three states a result slot can be in + * + * PENDING is the zero value on purpose: a freshly allocated slot is kernel-zeroed arena + * memory, so a slot that was never written reads as pending in every process without + * anybody having to initialize it. + */ +enum ResultState: int +{ + case Pending = 0; + + /** + * The coroutine returned; the slot carries its value record + */ + case Done = 1; + + /** + * The coroutine threw; the slot carries the address of a shared error-info object + */ + case Panic = 2; +} diff --git a/src/Ipc/SharedArray.php b/src/Ipc/SharedArray.php new file mode 100644 index 0000000..8b08b4a --- /dev/null +++ b/src/Ipc/SharedArray.php @@ -0,0 +1,237 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; +use Lisachenko\SharedData\Shm\ArenaAllocator; + +/** + * A fixed-capacity vector of value records, mutable by every process of the family + * + * This is the container a plain PHP array cannot be. A `zend_array` grows by reallocating + * its bucket block through the engine's allocator, which for arena memory means the block + * silently moves into the private heap of whichever worker triggered the growth - and the + * spike showed the engine writes that private pointer into the SHARED struct before it + * aborts, so siblings go on reading plausible garbage with no signal at all (EPIC #15, + * correction #6). A container that never grows has no such moment: capacity is decided when + * it is created, an index outside it is a typed error, and every slot is one 16-byte record. + * + * ```text + * header (2 words) capacity | reserved + * slots capacity records of 16 bytes, all NIL to begin with + * ``` + * + * ## Locking: a stripe from the bank, taken on reads as well as writes + * + * The instance lock is `Arena::stripeFor($address)` - unrelated arrays usually land on + * different stripes, and two that collide merely serialize. Both halves of an element access + * take it, because reading a tag and a payload together is exactly the two-word read that was + * measured to tear (~1.3 % of unlocked reads saw two generations - correction #1). Only a + * single aligned word may be read without the lock (correction #2), which is what count() + * and the capacity read below do; skipping the lock on element reads would need a proof that + * the tag cannot change under the reader, and v1 does not make that promise. + * + * @implements \ArrayAccess + * @implements \IteratorAggregate + */ +final class SharedArray implements \ArrayAccess, \Countable, \IteratorAggregate +{ + private const int WORD_CAPACITY = 0; + private const int HEADER_WORDS = 2; + + private readonly Arena $arena; + + private readonly int $capacity; + + private readonly int $stripe; + + private bool $recoveredLock = false; + + private function __construct( + private readonly ArenaAllocator $allocator, + private readonly ValueCodec $codec, + private readonly int $address, + ) { + $this->arena = $allocator->arena(); + if (!$this->arena->contains($address, self::HEADER_WORDS * 8)) { + throw IpcException::notShared('shared array', $address); + } + $this->capacity = $this->arena->readWord($address + self::WORD_CAPACITY * 8); + $this->stripe = $this->arena->stripeFor($address); + } + + /** + * Creates a shared array of exactly $capacity slots, every one of them null + * + * @param string|null $name Roots-directory name siblings can find the array by + */ + public static function create( + ArenaAllocator $allocator, + ValueCodec $codec, + int $capacity, + ?string $name = null, + ): self { + if ($capacity <= 0) { + throw IpcException::invalidCapacity('Shared array', $capacity); + } + $arena = $allocator->arena(); + $address = $arena->allocate(self::HEADER_WORDS * 8 + $capacity * ValueRecord::SIZE, 16); + + // Kernel-zeroed memory already reads as capacity slots of tag NIL + $arena->writeWord($address + self::WORD_CAPACITY * 8, $capacity); + + if ($name !== null) { + $arena->putRoot($name, $address); + } + + return new self($allocator, $codec, $address); + } + + /** + * Binds a shared array another process created, by address + */ + public static function attach(ArenaAllocator $allocator, ValueCodec $codec, int $address): self + { + return new self($allocator, $codec, $address); + } + + /** + * Binds a shared array published in the arena roots directory + */ + public static function open(ArenaAllocator $allocator, ValueCodec $codec, string $name): self + { + return new self($allocator, $codec, $allocator->arena()->requireRoot($name)); + } + + /** + * Address of the header - what a value record referencing this array carries + */ + public function address(): int + { + return $this->address; + } + + /** + * Fixed number of slots; this never changes for the lifetime of the array + */ + #[\Override] + public function count(): int + { + return $this->capacity; + } + + /** + * Whether a lock of this array was ever recovered from a worker that died holding it + */ + public function wasLockRecovered(): bool + { + return $this->recoveredLock; + } + + /** + * @param int $offset + */ + #[\Override] + public function offsetExists(mixed $offset): bool + { + return \is_int($offset) && $offset >= 0 && $offset < $this->capacity; + } + + /** + * @param int $offset + */ + #[\Override] + public function offsetGet(mixed $offset): mixed + { + $slot = $this->slotAddress($offset); + + $recovered = $this->arena->lockStripe($this->stripe); + + $tag = $this->arena->readWord($slot); + $payload = $this->arena->readWord($slot + 8); + + $this->arena->unlockStripe($this->stripe); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + // Materializing a value is an engine call that allocates: never under the lock + return $this->codec->decode(ValueTag::from($tag), $payload); + } + + /** + * @param int|null $offset + */ + #[\Override] + public function offsetSet(mixed $offset, mixed $value): void + { + if ($offset === null) { + // Appending would mean growing, and growth is the one thing this container cannot do + throw IpcException::outOfRange($this->capacity, $this->capacity); + } + $slot = $this->slotAddress($offset); + // Encoded before the lock: interning a string allocates arena memory, and a value + // that cannot be shared has to throw without ever taking a lock + [$tag, $payload] = $this->codec->encode($value); + + $recovered = $this->arena->lockStripe($this->stripe); + + $this->arena->writeWord($slot, $tag->value); + $this->arena->writeWord($slot + 8, $payload); + + $this->arena->unlockStripe($this->stripe); + + $this->recoveredLock = $this->recoveredLock || $recovered; + } + + /** + * Clears a slot back to null; the slot itself stays, because capacity is fixed + * + * @param int $offset + */ + #[\Override] + public function offsetUnset(mixed $offset): void + { + $this->offsetSet($offset, null); + } + + /** + * @return \Traversable + */ + #[\Override] + public function getIterator(): \Traversable + { + for ($index = 0; $index < $this->capacity; $index++) { + yield $index => $this->offsetGet($index); + } + } + + /** + * Every slot as an ordinary PHP array - a REQUEST-local copy of the current values + * + * @return array + */ + public function toArray(): array + { + return iterator_to_array($this->getIterator()); + } + + private function slotAddress(mixed $offset): int + { + if (!\is_int($offset) || $offset < 0 || $offset >= $this->capacity) { + throw IpcException::outOfRange(\is_int($offset) ? $offset : -1, $this->capacity); + } + + return $this->address + self::HEADER_WORDS * 8 + $offset * ValueRecord::SIZE; + } +} diff --git a/src/Ipc/SharedChannel.php b/src/Ipc/SharedChannel.php new file mode 100644 index 0000000..7ae3e80 --- /dev/null +++ b/src/Ipc/SharedChannel.php @@ -0,0 +1,568 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; +use Lisachenko\SharedData\Shm\ArenaAllocator; + +/** + * A Go-style channel whose ring, waiters and close flag all live in the shared arena + * + * Nothing about a channel is per-process except the descriptors it wakes people through: the + * ring of 16-byte records, the head and tail counters, the closed flag and the two waiter + * tables are arena memory, so a producer in one worker and a consumer in another operate on + * one structure rather than on two views that have to be reconciled. Values never leave the + * shared area on the way: send() writes a record, recv() reads one, and the socket in between + * carries a fixed wake record with no payload in it (see WakeEvent). + * + * ```text + * header (8 words) capacity | head | tail | closed | mutex address | + * waiter capacity | receivers parked | senders parked + * receivers table waiter capacity words - wake slots of parked receivers + * senders table waiter capacity words - wake slots of parked senders + * ring max(capacity, 1) records of 16 bytes + * ``` + * + * `head` and `tail` are MONOTONIC counters, never wrapped indexes: `tail - head` is the fill + * level, the slot is `counter % slots`, and a sender that deposited at ticket N knows its + * record was taken the moment `head > N`. That is what makes the rendezvous handshake a + * single word comparison instead of a state machine. + * + * ## One dedicated mutex per channel, and the whole ring op under it + * + * The lock is a robust process-shared mutex of the channel's OWN (Arena::allocateMutex()) + * rather than a stripe from the bank: a channel takes its lock on every single operation, and + * two busy channels sharing a stripe would serialize against each other for no reason. The + * entire ring operation happens inside it - a 16-byte record store is not atomic (EPIC #15, + * correction #1), and a publish-payload-then-tag protocol would still leave the counters + * racing. Inside the critical section there is nothing but aligned word loads and stores: + * values are encoded before the lock is taken and decoded after it is released. + * + * ## Blocking here is a spin loop, not a scheduler + * + * send() and recv() park on the notification socket with a bounded slice and re-poll, which + * is the honest primitive a package with no scheduler can offer. A coroutine runtime wants + * the other half of the API: trySend()/tryRecv() plus notificationStream(), so it can park a + * Fiber in its own event loop and never block the process. Both halves observe the same + * waiter tables, so a Fiber-parked consumer and a spin-blocked one wake identically. + */ +final class SharedChannel +{ + public const int DEFAULT_WAITERS = 16; + + /** + * Longest a blocking call sleeps before it re-polls the shared state on its own + * + * A wake event is an optimization, never a correctness requirement: a dropped one (full + * socket buffer) costs at most this slice of latency. + */ + private const float WAIT_SLICE = 0.05; + + private const int WORD_CAPACITY = 0; + private const int WORD_HEAD = 1; + private const int WORD_TAIL = 2; + private const int WORD_CLOSED = 3; + private const int WORD_MUTEX = 4; + private const int WORD_WAITER_CAPACITY = 5; + private const int WORD_RECEIVERS_PARKED = 6; + private const int WORD_SENDERS_PARKED = 7; + private const int HEADER_WORDS = 8; + + private readonly Arena $arena; + + private readonly int $mutex; + + private readonly int $capacity; + + private readonly int $waiterCapacity; + + private readonly WaiterTable $receivers; + + private readonly WaiterTable $senders; + + private readonly int $ringAddress; + + private bool $recoveredLock = false; + + private function __construct( + private readonly ArenaAllocator $allocator, + private readonly ValueCodec $codec, + private readonly WakeRegistry $wake, + private readonly int $address, + ) { + $this->arena = $allocator->arena(); + if (!$this->arena->contains($address, self::HEADER_WORDS * 8)) { + throw IpcException::notShared('channel', $address); + } + + $this->capacity = $this->word(self::WORD_CAPACITY); + $this->mutex = $this->word(self::WORD_MUTEX); + $this->waiterCapacity = $this->word(self::WORD_WAITER_CAPACITY); + + $tables = $this->address + self::HEADER_WORDS * 8; + $this->receivers = new WaiterTable($this->arena, $tables, $this->waiterCapacity); + $this->senders = new WaiterTable( + $this->arena, + $tables + WaiterTable::bytesFor($this->waiterCapacity), + $this->waiterCapacity, + ); + $this->ringAddress = $tables + 2 * WaiterTable::bytesFor($this->waiterCapacity); + } + + /** + * Creates a channel in the arena and optionally publishes it under a name + * + * @param int $capacity Buffered records; 0 makes it a rendezvous channel, where a + * send only completes once a receiver has taken the value + * @param string|null $name Roots-directory name siblings can find the channel by + */ + public static function create( + ArenaAllocator $allocator, + ValueCodec $codec, + WakeRegistry $wake, + int $capacity, + int $waiterCapacity = self::DEFAULT_WAITERS, + ?string $name = null, + ): self { + if ($capacity < 0) { + throw IpcException::invalidCapacity('Channel', $capacity); + } + if ($waiterCapacity <= 0) { + throw IpcException::invalidCapacity('Channel waiter table', $waiterCapacity); + } + + $arena = $allocator->arena(); + $slots = max($capacity, 1); + $bytes = self::HEADER_WORDS * 8 + + 2 * WaiterTable::bytesFor($waiterCapacity) + + $slots * ValueRecord::SIZE; + + $address = $arena->allocate($bytes, 64); + $mutex = $arena->allocateMutex(); + + // Fresh arena memory is zero-filled by the kernel, so head, tail, the closed flag, + // both waiter tables and every ring record already read as empty + $arena->writeWord($address + self::WORD_CAPACITY * 8, $capacity); + $arena->writeWord($address + self::WORD_MUTEX * 8, $mutex); + $arena->writeWord($address + self::WORD_WAITER_CAPACITY * 8, $waiterCapacity); + + if ($name !== null) { + $arena->putRoot($name, $address); + } + + return new self($allocator, $codec, $wake, $address); + } + + /** + * Binds a channel another process created, by the address it published + */ + public static function attach( + ArenaAllocator $allocator, + ValueCodec $codec, + WakeRegistry $wake, + int $address, + ): self { + return new self($allocator, $codec, $wake, $address); + } + + /** + * Binds a channel published in the arena roots directory + */ + public static function open( + ArenaAllocator $allocator, + ValueCodec $codec, + WakeRegistry $wake, + string $name, + ): self { + return new self($allocator, $codec, $wake, $allocator->arena()->requireRoot($name)); + } + + /** + * Address of the channel header - the eight bytes that identify it between processes + */ + public function address(): int + { + return $this->address; + } + + public function capacity(): int + { + return $this->capacity; + } + + public function isRendezvous(): bool + { + return $this->capacity === 0; + } + + /** + * Records currently buffered (single-word reads, so no lock and no tearing) + */ + public function count(): int + { + return $this->word(self::WORD_TAIL) - $this->word(self::WORD_HEAD); + } + + public function isClosed(): bool + { + return $this->word(self::WORD_CLOSED) !== 0; + } + + /** + * The descriptor a scheduler parks on; drain it and re-poll tryRecv()/trySend() + * + * @return resource + */ + public function notificationStream() + { + return $this->wake->stream(); + } + + /** + * Whether a lock of this channel was ever recovered from a worker that died holding it + */ + public function wasLockRecovered(): bool + { + return $this->recoveredLock; + } + + /** + * Sends without ever blocking; false means "no room right now" + * + * On a rendezvous channel this succeeds only while a receiver is already parked, and it + * returns as soon as the record is deposited - the synchronous half of the handshake + * (waiting until the value is actually taken) is what send() adds on top. + */ + public function trySend(mixed $value): bool + { + [$tag, $payload] = $this->codec->encode($value); + + $ticket = $this->offer($tag, $payload, requireParkedReceiver: $this->isRendezvous()); + if ($ticket === null) { + return false; + } + $this->wakeReceivers($tag, $payload); + + return true; + } + + /** + * Sends, waiting for room (and, on a rendezvous channel, for a receiver to take the value) + * + * @param float|null $timeout Seconds to wait; null waits forever + * + * @return bool Whether the value was sent + */ + public function send(mixed $value, ?float $timeout = null): bool + { + // Encoded once, outside every critical section: interning a string allocates arena + // memory, and a rejected value must throw before any lock is taken + [$tag, $payload] = $this->codec->encode($value); + $deadline = $timeout === null ? null : microtime(true) + $timeout; + + while (true) { + $ticket = $this->offer($tag, $payload, requireParkedReceiver: false); + if ($ticket !== null) { + $this->wakeReceivers($tag, $payload); + + return $this->isRendezvous() ? $this->awaitTaken($ticket, $deadline) : true; + } + if (!$this->parkForRoom($deadline)) { + return false; + } + } + } + + /** + * Takes a record if one is buffered + * + * @return array{0: mixed, 1: bool}|null The value and true, [null, false] on a drained + * closed channel, or null when nothing is ready + */ + public function tryRecv(): ?array + { + $recovered = $this->arena->lockMutexAt($this->mutex); + + $head = $this->word(self::WORD_HEAD); + $tail = $this->word(self::WORD_TAIL); + $closed = $this->word(self::WORD_CLOSED); + $tag = ValueTag::Nil->value; + $load = 0; + $took = $tail > $head; + if ($took) { + $slot = $this->slotAddress($head); + $tag = $this->arena->readWord($slot); + $load = $this->arena->readWord($slot + 8); + $this->setWord(self::WORD_HEAD, $head + 1); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if (!$took) { + return $closed !== 0 ? [null, false] : null; + } + + // A slot became free: senders parked on a full ring (or on a rendezvous handshake) + // are told after the lock is gone, never while holding it + $this->wakeSenders(); + + return [$this->codec->decode(ValueTag::from($tag), $load), true]; + } + + /** + * Receives, waiting for a record to arrive + * + * @param float|null $timeout Seconds to wait; null waits forever + * + * @return array{0: mixed, 1: bool} [value, true], or [null, false] once a closed channel + * has been drained (and on timeout) + */ + public function recv(?float $timeout = null): array + { + $deadline = $timeout === null ? null : microtime(true) + $timeout; + + while (true) { + $received = $this->tryRecv(); + if ($received !== null) { + return $received; + } + if (!$this->parkForRecord($deadline)) { + return [null, false]; + } + } + } + + /** + * Closes the channel for every process, then wakes everybody parked on it + * + * Buffered records survive: receivers drain them and only then see the end of stream. + */ + public function close(): void + { + $recovered = $this->arena->lockMutexAt($this->mutex); + + $this->setWord(self::WORD_CLOSED, 1); + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + $event = new WakeEvent(WakeOpcode::Close, $this->channelId(), ValueTag::Close); + $this->wake->notifyAll($this->receivers->occupants(), $event); + $this->wake->notifyAll($this->senders->occupants(), $event); + } + + /** + * Writes one record into the ring if it fits, and returns the ticket it was written at + * + * @param bool $requireParkedReceiver Rendezvous handoff only: refuse to deposit while no + * receiver is waiting to take the value + * + * @return int|null Monotonic ticket of the deposited record, or null when there was no room + */ + private function offer(ValueTag $tag, int $payload, bool $requireParkedReceiver): ?int + { + $limit = max($this->capacity, 1); + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $closed = $this->word(self::WORD_CLOSED); + $head = $this->word(self::WORD_HEAD); + $tail = $this->word(self::WORD_TAIL); + $parked = $this->word(self::WORD_RECEIVERS_PARKED); + $accepted = $closed === 0 + && $tail - $head < $limit + && (!$requireParkedReceiver || $parked > 0); + if ($accepted) { + $slot = $this->slotAddress($tail); + $this->arena->writeWord($slot, $tag->value); + $this->arena->writeWord($slot + 8, $payload); + $this->setWord(self::WORD_TAIL, $tail + 1); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if ($closed !== 0) { + throw ClosedChannelException::onSend($this->address); + } + + return $accepted ? $tail : null; + } + + /** + * Parks until the ring has room again + * + * @return bool Whether it is worth retrying (false = the deadline passed) + */ + private function parkForRoom(?float $deadline): bool + { + $limit = max($this->capacity, 1); + $wakeSlot = $this->wake->slot(); + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $hasRoom = $this->word(self::WORD_TAIL) - $this->word(self::WORD_HEAD) < $limit; + $entry = null; + if (!$hasRoom) { + // Registered and re-checked in ONE critical section: a receiver that frees a slot + // after this point necessarily sees this entry, so the wakeup cannot be lost + $entry = $this->senders->register($wakeSlot); + $this->setWord(self::WORD_SENDERS_PARKED, $this->word(self::WORD_SENDERS_PARKED) + 1); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if ($hasRoom) { + return true; + } + + $continue = $this->sleep($deadline); + $this->unpark($this->senders, $entry, self::WORD_SENDERS_PARKED); + + return $continue; + } + + /** + * Parks until a record shows up (or the channel is closed, which also ends the wait) + */ + private function parkForRecord(?float $deadline): bool + { + $wakeSlot = $this->wake->slot(); + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $ready = $this->word(self::WORD_TAIL) > $this->word(self::WORD_HEAD) + || $this->word(self::WORD_CLOSED) !== 0; + $entry = null; + if (!$ready) { + $entry = $this->receivers->register($wakeSlot); + $this->setWord(self::WORD_RECEIVERS_PARKED, $this->word(self::WORD_RECEIVERS_PARKED) + 1); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if ($ready) { + return true; + } + + $continue = $this->sleep($deadline); + $this->unpark($this->receivers, $entry, self::WORD_RECEIVERS_PARKED); + + return $continue; + } + + /** + * Rendezvous half of send(): waits until a receiver has actually taken ticket $ticket + */ + private function awaitTaken(int $ticket, ?float $deadline): bool + { + while ($this->word(self::WORD_HEAD) <= $ticket) { + if ($this->isClosed()) { + throw ClosedChannelException::whileParked($this->address); + } + if (!$this->parkForRoom($deadline)) { + return false; + } + } + + return true; + } + + /** + * Deregisters a waiter entry, under the lock, and drops the parked counter with it + */ + private function unpark(WaiterTable $table, ?int $entry, int $counterWord): void + { + if ($entry === null) { + return; + } + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $table->release($entry); + $this->setWord($counterWord, max($this->word($counterWord) - 1, 0)); + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + } + + /** + * Waits on the notification socket for one slice, honouring the deadline + * + * @return bool Whether there is still time left to retry + */ + private function sleep(?float $deadline): bool + { + if ($deadline === null) { + $this->wake->wait(self::WAIT_SLICE); + + return true; + } + $remaining = $deadline - microtime(true); + if ($remaining <= 0) { + return false; + } + $this->wake->wait(min($remaining, self::WAIT_SLICE)); + + return true; + } + + private function wakeReceivers(ValueTag $tag, int $payload): void + { + $this->wake->notifyAll( + $this->receivers->occupants(), + WakeEvent::forValue(WakeOpcode::Wake, $this->channelId(), $tag, $payload), + ); + } + + private function wakeSenders(): void + { + $this->wake->notifyAll( + $this->senders->occupants(), + new WakeEvent(WakeOpcode::Wake, $this->channelId()), + ); + } + + /** + * Short, stable id of this channel for event records (the full address does not fit uint32) + */ + private function channelId(): int + { + return ($this->address >> 4) & 0xFFFFFFFF; + } + + private function slotAddress(int $counter): int + { + return $this->ringAddress + ($counter % max($this->capacity, 1)) * ValueRecord::SIZE; + } + + private function word(int $index): int + { + return $this->arena->readWord($this->address + $index * 8); + } + + private function setWord(int $index, int $value): void + { + $this->arena->writeWord($this->address + $index * 8, $value); + } +} diff --git a/src/Ipc/SharedError.php b/src/Ipc/SharedError.php new file mode 100644 index 0000000..83180f1 --- /dev/null +++ b/src/Ipc/SharedError.php @@ -0,0 +1,68 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\PersistentStore; + +/** + * What crosses a worker boundary when a coroutine throws: an object, not a rendered message + * + * A Throwable itself can never be shared - it is an internal class carrying C state, a + * backtrace of live frames and, usually, a previous exception chain - and serializing it is + * exactly what this package refuses to do. So the panic path persists a plain three-string + * object into the arena instead: the class name, the message and the formatted trace, each + * an arena-resident string. The result slot then carries its ADDRESS, and the waiting + * process attaches the same object rather than parsing anything. + * + * ## One error entry per store, deliberately + * + * The object is persisted under this class as its storage key, so capturing a second error + * replaces the first (persist() is an upsert). That fits the shape of the panic path - a + * worker captures the failure that ended its task and the waiter reads it - and it keeps the + * arena from filling up with the error graphs of a crash loop. A consumer that needs several + * live panics at once should copy the three strings out of the object it attached; in arena + * mode nothing is freed while the family lives (blocks are reclaimed only at teardown), but + * a superseded object leaves the registry and can no longer be attached by address. + */ +final class SharedError +{ + public string $className = ''; + + public string $message = ''; + + public string $trace = ''; + + /** + * Moves a Throwable's description into the arena and returns the shared object's address + * + * Returns the address rather than the instance on purpose: holding the persistent + * instance would make the NEXT capture fail, since the store refuses to release a graph + * the request can still reach. + * + * @return int Address of the shared error-info object, for a result slot record + */ + public static function capture(PersistentStore $store, \Throwable $error): int + { + $info = new self(); + $info->className = $error::class; + $info->message = $error->getMessage(); + $info->trace = $error->getTraceAsString(); + + $store->persist(self::class, $info); + $address = $store->addressOf(self::class); + \assert($address !== null); + + return $address; + } +} diff --git a/src/Ipc/SharedMutex.php b/src/Ipc/SharedMutex.php new file mode 100644 index 0000000..06032a7 --- /dev/null +++ b/src/Ipc/SharedMutex.php @@ -0,0 +1,163 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; + +/** + * A robust process-shared mutex userland can hold, without ever wedging the pool + * + * The lock itself is a `pthread_mutex_t` in the arena with PTHREAD_PROCESS_SHARED and + * PTHREAD_MUTEX_ROBUST set - the only cross-process synchronization primitive reachable from + * PHP, since FFI offers no atomics, no CAS and no fences. What this class adds is the policy + * around it that a userland caller needs: + * + * - **acquisition is bounded.** `lock()` is a trylock loop with backoff rather than a + * blocking `pthread_mutex_lock`, because a PHP process blocked in libc is a process that + * cannot run its scheduler, service its notification socket or answer a supervisor. A + * caller that genuinely wants to wait forever passes no timeout and still gets a loop. + * - **a died owner is recovered, never discarded.** EOWNERDEAD means the previous holder + * exited inside the critical section: the lock is granted, `pthread_mutex_consistent()` + * is called immediately (skipping it poisons the mutex arena-wide with ENOTRECOVERABLE, + * permanently - EPIC #15, correction #7), and the fact is reported through wasRecovered() + * so the caller can check whatever it was guarding. The result of a lock call is never + * thrown away. + * + * The same rules apply to the locks inside this package's own primitives; there they guard + * single word stores, so recovery is trivially safe. Here the guarded state is the CALLER'S, + * which is why recovery is surfaced rather than swallowed. + */ +final class SharedMutex +{ + /** + * Backoff bounds of the acquisition loop, in microseconds + */ + private const int MIN_BACKOFF = 20; + private const int MAX_BACKOFF = 2_000; + + private bool $recovered = false; + + private bool $held = false; + + private function __construct( + private readonly Arena $arena, + private readonly int $address, + ) { + } + + /** + * Creates a mutex in the arena, optionally publishing it under a name + */ + public static function create(Arena $arena, ?string $name = null): self + { + $address = $arena->allocateMutex(); + if ($name !== null) { + $arena->putRoot($name, $address); + } + + return new self($arena, $address); + } + + /** + * Binds a mutex another process created, by address + */ + public static function attach(Arena $arena, int $address): self + { + if (!$arena->contains($address, Arena::MUTEX_SLOT_SIZE)) { + throw IpcException::notShared('mutex', $address); + } + + return new self($arena, $address); + } + + /** + * Binds a mutex published in the arena roots directory + */ + public static function open(Arena $arena, string $name): self + { + return self::attach($arena, $arena->requireRoot($name)); + } + + public function address(): int + { + return $this->address; + } + + /** + * Takes the lock if it is free right now + */ + public function tryLock(): bool + { + $recovered = false; + $taken = $this->arena->tryLockMutexAt($this->address, $recovered); + if ($taken) { + // A recovered lock IS acquired: the EOWNERDEAD answer travels separately so + // that neither half of the result can be dropped by accident + $this->held = true; + $this->recovered = $this->recovered || $recovered; + } + + return $taken; + } + + /** + * Takes the lock, retrying with backoff until it is free or the deadline passes + * + * @param float|null $timeout Seconds to keep trying; null retries forever + * + * @return bool Whether the lock is now held by this process + */ + public function lock(?float $timeout = null): bool + { + $deadline = $timeout === null ? null : microtime(true) + $timeout; + $backoff = self::MIN_BACKOFF; + + while (true) { + if ($this->tryLock()) { + return true; + } + if ($deadline !== null && microtime(true) >= $deadline) { + return false; + } + usleep($backoff); + $backoff = min($backoff * 2, self::MAX_BACKOFF); + } + } + + public function unlock(): void + { + $this->held = false; + $this->arena->unlockMutexAt($this->address); + } + + /** + * Whether this process currently believes it holds the lock + */ + public function isHeld(): bool + { + return $this->held; + } + + /** + * Whether any acquisition through this handle inherited the lock from a died owner + * + * True means some worker exited inside the critical section: the mutex was made + * consistent again, and whatever it guards has to be checked by the code that knows + * what "consistent" means for that structure. + */ + public function wasRecovered(): bool + { + return $this->recovered; + } +} diff --git a/src/Ipc/SharedWaitGroup.php b/src/Ipc/SharedWaitGroup.php new file mode 100644 index 0000000..2a3cf6e --- /dev/null +++ b/src/Ipc/SharedWaitGroup.php @@ -0,0 +1,242 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; + +/** + * "Wait until the outstanding work reaches zero", across processes + * + * A counter in the arena plus a waiter table: add() before handing work out, done() when a + * worker finishes a unit, wait() to park until the count is zero. The counter is shared, so + * it does not matter which process increments and which decrements - a parent may add() for + * four children and any of them may done() from wherever it runs. + * + * ```text + * header (4 words) counter | mutex address | waiter capacity | waiters parked + * waiters waiter capacity words - wake slots parked on zero + * ``` + * + * A negative counter is a hard error rather than a clamp: done() called more often than + * add() means the family lost track of its own work, and continuing would let wait() return + * while units are still running. The counter is left where it was so the miscount is visible. + */ +final class SharedWaitGroup +{ + public const int DEFAULT_WAITERS = 16; + + private const float WAIT_SLICE = 0.05; + + private const int WORD_COUNTER = 0; + private const int WORD_MUTEX = 1; + private const int WORD_WAITER_CAPACITY = 2; + private const int WORD_PARKED = 3; + private const int HEADER_WORDS = 4; + + private readonly int $mutex; + + private readonly WaiterTable $waiters; + + private bool $recoveredLock = false; + + private function __construct( + private readonly Arena $arena, + private readonly WakeRegistry $wake, + private readonly int $address, + ) { + if (!$arena->contains($address, self::HEADER_WORDS * 8)) { + throw IpcException::notShared('wait group', $address); + } + $this->mutex = $arena->readWord($address + self::WORD_MUTEX * 8); + $this->waiters = new WaiterTable( + $arena, + $address + self::HEADER_WORDS * 8, + $arena->readWord($address + self::WORD_WAITER_CAPACITY * 8), + ); + } + + public static function create( + Arena $arena, + WakeRegistry $wake, + int $waiterCapacity = self::DEFAULT_WAITERS, + ?string $name = null, + ): self { + if ($waiterCapacity <= 0) { + throw IpcException::invalidCapacity('Wait group waiter table', $waiterCapacity); + } + $address = $arena->allocate(self::HEADER_WORDS * 8 + WaiterTable::bytesFor($waiterCapacity), 64); + $mutex = $arena->allocateMutex(); + + $arena->writeWord($address + self::WORD_MUTEX * 8, $mutex); + $arena->writeWord($address + self::WORD_WAITER_CAPACITY * 8, $waiterCapacity); + + if ($name !== null) { + $arena->putRoot($name, $address); + } + + return new self($arena, $wake, $address); + } + + public static function attach(Arena $arena, WakeRegistry $wake, int $address): self + { + return new self($arena, $wake, $address); + } + + public static function open(Arena $arena, WakeRegistry $wake, string $name): self + { + return new self($arena, $wake, $arena->requireRoot($name)); + } + + public function address(): int + { + return $this->address; + } + + /** + * Outstanding units of work (single aligned word read, so no lock) + */ + public function count(): int + { + return $this->arena->readWord($this->address + self::WORD_COUNTER * 8); + } + + /** + * Announces $delta more units of work + */ + public function add(int $delta = 1): int + { + return $this->adjust($delta); + } + + /** + * Marks one unit finished, waking everybody parked once the count reaches zero + */ + public function done(): int + { + $value = $this->adjust(-1); + if ($value === 0) { + $this->wake->notifyAll( + $this->waiters->occupants(), + new WakeEvent(WakeOpcode::Wake, ($this->address >> 4) & 0xFFFFFFFF), + ); + } + + return $value; + } + + /** + * Parks until the counter reaches zero + * + * @param float|null $timeout Seconds to wait; null waits forever + * + * @return bool Whether the counter actually reached zero + */ + public function wait(?float $timeout = null): bool + { + $deadline = $timeout === null ? null : microtime(true) + $timeout; + $wakeSlot = $this->wake->slot(); + + while (true) { + $recovered = $this->arena->lockMutexAt($this->mutex); + + $reached = $this->arena->readWord($this->address + self::WORD_COUNTER * 8) <= 0; + $entry = null; + if (!$reached) { + // Registering and re-reading the counter in ONE critical section is what + // makes a lost wakeup impossible: a done() that zeroes the counter after + // this point necessarily sees this entry + $entry = $this->waiters->register($wakeSlot); + $this->bumpParked(1); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if ($reached) { + return true; + } + if ($deadline === null) { + $this->wake->wait(self::WAIT_SLICE); + } else { + $remaining = $deadline - microtime(true); + if ($remaining > 0) { + $this->wake->wait(min($remaining, self::WAIT_SLICE)); + } + } + + $this->unpark($entry); + + if ($deadline !== null && microtime(true) >= $deadline) { + return $this->count() <= 0; + } + } + } + + /** + * Whether a lock of this group was ever recovered from a worker that died holding it + */ + public function wasLockRecovered(): bool + { + return $this->recoveredLock; + } + + private function adjust(int $delta): int + { + $recovered = $this->arena->lockMutexAt($this->mutex); + + $value = $this->arena->readWord($this->address + self::WORD_COUNTER * 8) + $delta; + $negative = $value < 0; + if (!$negative) { + $this->arena->writeWord($this->address + self::WORD_COUNTER * 8, $value); + } + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if ($negative) { + // The counter keeps its old value on purpose: the miscount stays visible to + // every process instead of being papered over with a clamp to zero + throw IpcException::negativeCounter($value); + } + + return $value; + } + + private function unpark(?int $entry): void + { + if ($entry === null) { + return; + } + + $recovered = $this->arena->lockMutexAt($this->mutex); + + $this->waiters->release($entry); + $this->bumpParked(-1); + + $this->arena->unlockMutexAt($this->mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + } + + /** + * Moves the parked counter; the caller holds the group's lock + */ + private function bumpParked(int $delta): void + { + $parked = $this->arena->readWord($this->address + self::WORD_PARKED * 8) + $delta; + $this->arena->writeWord($this->address + self::WORD_PARKED * 8, max($parked, 0)); + } +} diff --git a/src/Ipc/SlotResult.php b/src/Ipc/SlotResult.php new file mode 100644 index 0000000..574f61a --- /dev/null +++ b/src/Ipc/SlotResult.php @@ -0,0 +1,47 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +/** + * What a result slot holds right now: its state, and the value materialized from its record + * + * The value is built from the shared record AFTER the slot lock is released, so a SlotResult + * is an ordinary request-scoped value object. For a PANIC it carries the shared error-info + * object (see SharedError), which is a real object in the arena and not a rendered message. + */ +final class SlotResult +{ + public function __construct( + public readonly int $id, + public readonly ResultState $state, + public readonly mixed $value = null, + public readonly ValueTag $tag = ValueTag::Nil, + ) { + } + + public function isPending(): bool + { + return $this->state === ResultState::Pending; + } + + public function isDone(): bool + { + return $this->state === ResultState::Done; + } + + public function isPanic(): bool + { + return $this->state === ResultState::Panic; + } +} diff --git a/src/Ipc/ValueCodec.php b/src/Ipc/ValueCodec.php new file mode 100644 index 0000000..57b12c4 --- /dev/null +++ b/src/Ipc/ValueCodec.php @@ -0,0 +1,212 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\PersistentStore; +use Lisachenko\SharedData\Shm\Arena; +use Lisachenko\SharedData\Shm\ArenaAllocator; +use ZEngine\Core; +use ZEngine\Generated\zend_string; +use ZEngine\Type\StringEntry; + +/** + * Turns PHP values into 16-byte records and back, without ever encoding one + * + * This class is where the Never-Serialize Rule is actually enforced. There is no branch in + * it that produces bytes describing a value: a scalar IS the payload word, a string becomes + * an arena-resident zend_string and the payload is its address, an object and a shared array + * contribute nothing but their address. Anything that has no address-shaped form - a plain + * array, a resource, a closure, an object this worker family does not share - is refused + * with NotShareableValueException rather than quietly encoded. + * + * ## Sending a string costs arena bytes + * + * Strings are interned into the arena on the way in (`StringEntry::persistentInterned()` + * through the ArenaAllocator), which is a structural memcpy of the bytes into shared memory, + * not a serialization: the receiver ends up with a real `zend_string` at a real address. + * The arena is bump-allocated and never frees, so each distinct send consumes bytes for the + * lifetime of the family - a workload that streams unbounded strings must size the arena for + * it (see Arena's allocation model). Scalars, objects and shared arrays consume nothing. + * + * ## Receiving is zero-copy + * + * A string record materializes as a PHP string pointing straight at the arena block: the + * zval is non-refcounted because the block is flagged immutable, exactly like an interned or + * opcache-SHM string, so the engine copies the POINTER around and copy-on-writes into + * request memory if userland mutates it. An object record materializes through the store, + * which registers the shared zend_object in this request's object store; the address the + * sender saw and the address the receiver sees are the same eight bytes. + */ +final class ValueCodec +{ + /** + * @param ArenaAllocator $allocator Source of arena memory for string records + * @param PersistentStore|null $store Registry that decides which objects are shared; + * without one, object records cannot be built + */ + public function __construct( + private readonly ArenaAllocator $allocator, + private readonly ?PersistentStore $store = null, + ) { + } + + public function arena(): Arena + { + return $this->allocator->arena(); + } + + public function allocator(): ArenaAllocator + { + return $this->allocator; + } + + public function store(): ?PersistentStore + { + return $this->store; + } + + /** + * Converts a PHP value into the tag and payload of a record + * + * Always called OUTSIDE the lock of the structure the record is going into: interning a + * string allocates arena memory (which takes the allocator mutex) and rejecting a value + * throws - neither is allowed while a ring or slot lock is held. + * + * @return array{0: ValueTag, 1: int} + */ + public function encode(mixed $value): array + { + return match (true) { + $value === null => [ValueTag::Nil, 0], + $value === true => [ValueTag::True, 0], + $value === false => [ValueTag::False, 0], + \is_int($value) => [ValueTag::Int, $value], + \is_float($value) => [ValueTag::Float, self::floatBits($value)], + \is_string($value) => [ValueTag::Str, $this->internString($value)], + \is_array($value) => throw NotShareableValueException::plainArray(), + \is_object($value) => $this->encodeObject($value), + \is_resource($value) => throw NotShareableValueException::resource(), + default => throw NotShareableValueException::unsupportedType(\gettype($value)), + }; + } + + /** + * Materializes the PHP value a record describes + * + * Called OUTSIDE the lock as well: attaching an object registers it in the object store + * and materializing a string builds a zval, both of which are engine calls that allocate. + */ + public function decode(ValueTag $tag, int $payload): mixed + { + return match ($tag) { + ValueTag::Nil, ValueTag::Close => null, + ValueTag::True => true, + ValueTag::False => false, + ValueTag::Int => $payload, + ValueTag::Float => self::bitsToFloat($payload), + ValueTag::Str => $this->readString($payload), + ValueTag::Obj => $this->attachObject($payload), + ValueTag::Arr => SharedArray::attach($this->allocator, $this, $payload), + }; + } + + /** + * Interns a string into the arena and returns the address of the zend_string + */ + private function internString(string $value): int + { + $interned = StringEntry::persistentInterned($value, $this->allocator); + + return Core::addressOf($interned->getRawValue()); + } + + /** + * Rebuilds a PHP string over the arena block at $address, without copying its bytes + */ + private function readString(int $address): string + { + $this->assertShared($address); + + return StringEntry::fromCData(Core::pointerAtAddress(zend_string::class, $address))->getStringValue(); + } + + /** + * @return array{0: ValueTag, 1: int} + */ + private function encodeObject(object $value): array + { + if ($value instanceof SharedArray) { + return [ValueTag::Arr, $value->address()]; + } + if ($value instanceof \Closure) { + // Provenance, never shape: a stale post-fork closure address can hold a valid + // Closure of a DIFFERENT function (EPIC #15, correction #8), so inspection can + // never establish that sharing this one is safe + throw NotShareableValueException::closure(); + } + if ($this->store === null) { + throw NotShareableValueException::withoutStore($value::class); + } + + $address = $this->store->addressOfInstance($value); + if ($address === null) { + throw NotShareableValueException::foreignObject($value::class); + } + + return [ValueTag::Obj, $address]; + } + + private function attachObject(int $address): object + { + $this->assertShared($address); + if ($this->store === null) { + throw NotShareableValueException::withoutStore('the referenced class'); + } + + return $this->store->attachObject($address); + } + + /** + * Refuses an address that does not point into the arena + * + * A record whose payload leads outside the shared mapping is either a bug or memory the + * receiving process cannot follow; either way, dereferencing it is what the bounds check + * exists to prevent (EPIC #15, correction #6). + */ + private function assertShared(int $address): void + { + if (!$this->arena()->contains($address, 8)) { + throw NotShareableValueException::foreignAddress($address); + } + } + + /** + * IEEE-754 bit pattern of a double, as a signed word the arena can store + */ + private static function floatBits(float $value): int + { + /** @var array{1: int} $bits */ + $bits = unpack('q', pack('d', $value)); + + return $bits[1]; + } + + private static function bitsToFloat(int $bits): float + { + /** @var array{1: float} $value */ + $value = unpack('d', pack('q', $bits)); + + return $value[1]; + } +} diff --git a/src/Ipc/ValueRecord.php b/src/Ipc/ValueRecord.php new file mode 100644 index 0000000..d5c39a0 --- /dev/null +++ b/src/Ipc/ValueRecord.php @@ -0,0 +1,73 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; + +/** + * The 16 bytes every value occupies while it sits in the shared area + * + * ```text + * 0 uint8 tag one of ValueTag + * 1 7 bytes padding always zero, so the tag word reads back as the bare tag + * 8 uint64 payload the value, or an arena address (see ValueTag) + * ``` + * + * Two aligned words, which is what makes a record cheap to move: a ring slot, an array + * element and a result slot are all "one record", written with two word stores and nothing + * else. It is emphatically NOT a zval - the engine's 16 bytes carry a type_info word whose + * flags mean refcounting, and a shared record must never imply a reference on anything. + * + * ## Why every read of a record happens under a lock + * + * A 16-byte store is NOT atomic: the concurrency spike measured ~1.3 % of unlocked + * two-word reads seeing a tag and a payload from different generations (EPIC #15, + * correction #1). An aligned 8-byte read alone never tears (correction #2), which is why + * single-word state (a head counter, a waiter entry, a plain AtomicInt load) may be read + * without the lock - but tag and payload together may not. + */ +final class ValueRecord +{ + public const int SIZE = 16; + public const int WORDS = 2; + + private function __construct() + { + } + + /** + * Stores a record; the caller holds the lock guarding $address + */ + public static function write(Arena $arena, int $address, ValueTag $tag, int $payload): void + { + $arena->writeWord($address, $tag->value); + $arena->writeWord($address + 8, $payload); + } + + /** + * Reads the tag word of a record; the caller holds the lock guarding $address + */ + public static function readTag(Arena $arena, int $address): ValueTag + { + return ValueTag::from($arena->readWord($address)); + } + + /** + * Reads the payload word of a record; the caller holds the lock guarding $address + */ + public static function readPayload(Arena $arena, int $address): int + { + return $arena->readWord($address + 8); + } +} diff --git a/src/Ipc/ValueTag.php b/src/Ipc/ValueTag.php new file mode 100644 index 0000000..904f4bd --- /dev/null +++ b/src/Ipc/ValueTag.php @@ -0,0 +1,75 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +/** + * What the eight payload bytes of a value record mean + * + * The tag is the entire type system of the shared area: a value that crosses a worker + * boundary is one of these nine shapes and nothing else. Three of them carry no payload at + * all, two carry the value itself, three carry an ADDRESS inside the arena, and one is the + * control tag channels use to publish their end of stream. + * + * Deliberately absent: any tag that would mean "a byte encoding of a PHP value graph". + * Serialization is what the arena exists to avoid - see ValueCodec. + */ +enum ValueTag: int +{ + case Nil = 0; + + case True = 1; + + case False = 2; + + /** + * Payload is the signed 64-bit value itself + */ + case Int = 3; + + /** + * Payload is the IEEE-754 bit pattern of the double (pack('d') / unpack('q')) + */ + case Float = 4; + + /** + * Payload is the address of an arena-resident, immutable zend_string + */ + case Str = 5; + + /** + * Payload is the address of a shared zend_object the registry knows + */ + case Obj = 6; + + /** + * Payload is the address of a SharedArray header + */ + case Arr = 7; + + /** + * Control record: this end of the stream is finished (no payload) + */ + case Close = 8; + + /** + * Whether the payload is an arena address rather than a value + * + * The notification plane uses this: an event record may carry the ADDRESS of a value + * (which is a pointer, not data), never the value itself - see WakeEvent. + */ + public function isAddress(): bool + { + return $this === self::Str || $this === self::Obj || $this === self::Arr; + } +} diff --git a/src/Ipc/WaiterTable.php b/src/Ipc/WaiterTable.php new file mode 100644 index 0000000..07eff36 --- /dev/null +++ b/src/Ipc/WaiterTable.php @@ -0,0 +1,98 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; + +/** + * Who to poke: a fixed table of wake-registry slots parked on one structure + * + * One word per entry, holding `wake slot + 1` so that a zero word means "free" without + * costing a separate occupancy flag. The table is not a queue and has no ordering: waking + * is level-triggered, so notifying everybody parked is always correct and notifying one + * more than necessary costs a socket write and a re-poll. + * + * ## Locking + * + * register() and release() MUTATE the table and must be called with the owning structure's + * lock held - two processes scanning for a free word without it could pick the same entry, + * and the loser would park with nobody knowing about it. occupants() only READS single + * aligned words, which never tear (EPIC #15, correction #2), and is deliberately used + * without the lock: a notifier reads the table AFTER publishing its record and releasing the + * mutex, so it never holds a lock while writing to a socket. + */ +final class WaiterTable +{ + public function __construct( + private readonly Arena $arena, + private readonly int $address, + private readonly int $capacity, + ) { + } + + /** + * Arena bytes a table of $capacity entries occupies + */ + public static function bytesFor(int $capacity): int + { + return $capacity * 8; + } + + /** + * Parks a wake slot; the caller holds the structure's lock + * + * @return int|null Entry index to hand to release(), or null when the table is full + * (the caller then falls back to polling with a bounded timeout) + */ + public function register(int $wakeSlot): ?int + { + for ($entry = 0; $entry < $this->capacity; $entry++) { + if ($this->arena->readWord($this->address + $entry * 8) === 0) { + $this->arena->writeWord($this->address + $entry * 8, $wakeSlot + 1); + + return $entry; + } + } + + return null; + } + + /** + * Frees an entry taken by register(); the caller holds the structure's lock + */ + public function release(?int $entry): void + { + if ($entry !== null) { + $this->arena->writeWord($this->address + $entry * 8, 0); + } + } + + /** + * Wake slots currently parked here, read without the lock (single-word loads) + * + * @return list + */ + public function occupants(): array + { + $slots = []; + for ($entry = 0; $entry < $this->capacity; $entry++) { + $parked = $this->arena->readWord($this->address + $entry * 8); + if ($parked !== 0) { + $slots[] = $parked - 1; + } + } + + return $slots; + } +} diff --git a/src/Ipc/WakeEvent.php b/src/Ipc/WakeEvent.php new file mode 100644 index 0000000..de0850c --- /dev/null +++ b/src/Ipc/WakeEvent.php @@ -0,0 +1,87 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +/** + * The only thing a socket of this package ever carries: 16 fixed bytes of signalling + * + * ```text + * 0 uint8 opcode WAKE | RESULT | PANIC | CLOSE + * 1 uint8 tag ValueTag of the value that became available (0 when irrelevant) + * 2 uint16 padding always zero + * 4 uint32 id channel id / result slot id the event is about + * 8 uint64 address arena ADDRESS of the value, and only when the tag is address-shaped + * ``` + * + * The address field is what makes this a pointer rather than a payload: a record whose tag + * is INT or FLOAT carries a zero there, because the value lives in the shared area and the + * socket has no business transporting it. The receiver's answer to any event is the same - + * go and re-read the shared state - which is why losing one is survivable and why an extra + * one is harmless. + * + * ## Level-triggered, so wakeups can be spurious but never lost + * + * A waiter registers itself in the structure's waiter table UNDER the structure's lock and + * re-checks the state in that same critical section. A notifier reads the waiter table after + * publishing its record. Either the waiter registered before the notifier looked - then it + * is notified - or it registered afterwards, in which case its own re-check under the lock + * already sees the published record and it never blocks. Both processes therefore agree + * without the socket being reliable at all: events are written non-blocking and a full pipe + * simply drops one, since every blocking loop also polls the state on a bounded timeout. + */ +final class WakeEvent +{ + public const int SIZE = 16; + + public function __construct( + public readonly WakeOpcode $opcode, + public readonly int $id = 0, + public readonly ValueTag $tag = ValueTag::Nil, + public readonly int $address = 0, + ) { + } + + /** + * Builds the event announcing a settled value, carrying its address only if it has one + */ + public static function forValue(WakeOpcode $opcode, int $id, ValueTag $tag, int $payload): self + { + return new self($opcode, $id, $tag, $tag->isAddress() ? $payload : 0); + } + + public function toBytes(): string + { + return pack('CCvVP', $this->opcode->value, $this->tag->value, 0, $this->id, $this->address); + } + + /** + * Parses one record, or null when the bytes are not a record this build understands + */ + public static function fromBytes(string $bytes): ?self + { + if (\strlen($bytes) !== self::SIZE) { + return null; + } + /** @var array{opcode: int, tag: int, pad: int, id: int, address: int} $fields */ + $fields = unpack('Copcode/Ctag/vpad/Vid/Paddress', $bytes); + + $opcode = WakeOpcode::tryFrom($fields['opcode']); + $tag = ValueTag::tryFrom($fields['tag']); + if ($opcode === null || $tag === null) { + return null; + } + + return new self($opcode, $fields['id'], $tag, $fields['address']); + } +} diff --git a/src/Ipc/WakeOpcode.php b/src/Ipc/WakeOpcode.php new file mode 100644 index 0000000..d0428d8 --- /dev/null +++ b/src/Ipc/WakeOpcode.php @@ -0,0 +1,44 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +/** + * Why a process is being woken - the first byte of every event record + * + * The set is deliberately tiny and closed: a receiver reacts to all four the same way (go + * and re-read the shared state), so the opcode is diagnostic information and a scheduling + * hint, never a protocol the correctness of a wait depends on. + */ +enum WakeOpcode: int +{ + /** + * A structure changed state: a ring became non-empty or non-full, a wait group hit zero + */ + case Wake = 1; + + /** + * A result slot was completed with a value + */ + case Result = 2; + + /** + * A result slot was completed with an error-info object + */ + case Panic = 3; + + /** + * A channel was closed; receivers drain what is left and then see the end of stream + */ + case Close = 4; +} diff --git a/src/Ipc/WakeRegistry.php b/src/Ipc/WakeRegistry.php new file mode 100644 index 0000000..98a0a1e --- /dev/null +++ b/src/Ipc/WakeRegistry.php @@ -0,0 +1,393 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Shm\Arena; + +/** + * The notification plane: one socket pair per process, carrying event records and nothing else + * + * Shared memory can hold state but cannot wake anybody: FFI offers no futex, no condition + * variable and no atomics, and a robust pthread mutex only serializes access. So blocking is + * done the one way PHP can actually do it - a descriptor a process can select() on - while + * every VALUE stays in the arena. A sender that makes a channel non-empty or completes a + * result slot writes ONE 16-byte WakeEvent to each parked process's socket; the receiver + * wakes, drains, and re-reads the shared state to find out what actually happened. + * + * ## Why the pairs must exist before the fork + * + * A file descriptor is per-process: the arena can carry an address that means the same thing + * everywhere, but never a handle. The only way for process A to write into process B's queue + * is to hold a descriptor of it, and the only way to get one without descriptor passing is to + * INHERIT it. create() therefore mints every pair up front, before any worker exists, and the + * whole registry travels into the children as ordinary forked state. The arena side of the + * registry is just the claim table: which pid owns which slot, so a notifier can turn "the + * waiter parked in this structure" into "the pair I write to". + * + * ## Claiming, and re-claiming after a fork + * + * slot() is idempotent per process. A forked child inherits its parent's claim as PHP state, + * notices the pid changed and claims an entry of its own, draining whatever its inherited + * read end still buffers (events addressed to the parent are not this process's business). + * Entries whose owner has died are recycled, so a supervisor may respawn workers forever + * without exhausting a table sized for the pool. + * + * ## The sockets are never a data path + * + * Every byte written here goes through writeRecord(), which accepts nothing but a 16-byte + * WakeEvent. observeWrites() exposes that single choke point so a test can prove the claim + * rather than assert it in prose. + */ +final class WakeRegistry +{ + public const string DEFAULT_ROOT = 'ipc.wake'; + + /** + * Processes a default registry can serve; two descriptors each, so it stays far below + * the usual 1024 open-file limit + */ + public const int DEFAULT_SLOTS = 32; + + private const int WORD_CAPACITY = 0; + private const int WORD_MUTEX = 1; + private const int HEADER_WORDS = 4; + + /** + * Read ends, one per slot; only the slot this process claimed is ever read from + * + * @var array + */ + private array $readers = []; + + /** + * Write ends, one per slot; any process may write to any of them + * + * @var array + */ + private array $writers = []; + + private ?int $slot = null; + + private int $ownerPid = 0; + + private bool $recoveredLock = false; + + /** + * Bytes read from this process's socket that did not complete a record yet + */ + private string $residue = ''; + + /** @var (callable(int, string): void)|null */ + private $observer = null; + + private function __construct( + private readonly Arena $arena, + private readonly int $address, + private readonly int $capacity, + ) { + } + + /** + * Mints the socket pairs and the claim table; call this ONCE, before any worker is forked + * + * @param int $slots Processes the registry can serve (pairs are created eagerly) + * @param string|null $name Roots-directory name to publish the claim table under + */ + public static function create( + Arena $arena, + int $slots = self::DEFAULT_SLOTS, + ?string $name = self::DEFAULT_ROOT, + ): self { + if ($slots <= 0) { + throw IpcException::invalidCapacity('Wake registry', $slots); + } + + $address = $arena->allocate((self::HEADER_WORDS + $slots) * 8, 64); + $mutex = $arena->allocateMutex(); + + $arena->writeWord($address + self::WORD_CAPACITY * 8, $slots); + $arena->writeWord($address + self::WORD_MUTEX * 8, $mutex); + + $registry = new self($arena, $address, $slots); + for ($slot = 0; $slot < $slots; $slot++) { + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); + if ($pair === false) { + throw IpcException::wakeRegistryNotInherited(); + } + stream_set_blocking($pair[0], false); + stream_set_blocking($pair[1], false); + + $registry->readers[$slot] = $pair[0]; + $registry->writers[$slot] = $pair[1]; + } + + if ($name !== null) { + $arena->putRoot($name, $address); + } + + return $registry; + } + + /** + * Address of the claim table (the arena half of the registry) + */ + public function address(): int + { + return $this->address; + } + + public function capacity(): int + { + return $this->capacity; + } + + /** + * This process's wake slot, claiming one on first use (and again after a fork) + */ + public function slot(): int + { + $pid = getmypid(); + if ($this->slot !== null && $this->ownerPid === $pid) { + return $this->slot; + } + + $slot = $this->claim((int) $pid); + + $this->slot = $slot; + $this->ownerPid = (int) $pid; + // Whatever the parent had queued belongs to the parent; this process starts level + $this->residue = ''; + $this->drain(); + + return $slot; + } + + /** + * The descriptor a scheduler selects on, so a consumer can integrate its own event loop + * + * Readiness means "something changed somewhere" and nothing more: drain it, then re-poll + * the structures this process is waiting on. That is the whole contract - the socket is + * level-triggered signalling, never a queue of values. + * + * @return resource + */ + public function stream() + { + $slot = $this->slot(); + + return $this->readers[$slot] ?? throw IpcException::wakeRegistryNotInherited(); + } + + /** + * Sends one event to a parked process + */ + public function notify(int $slot, WakeEvent $event): void + { + $writer = $this->writers[$slot] ?? null; + if ($writer === null) { + return; + } + $this->writeRecord($slot, $event, $writer); + } + + /** + * Sends one event to every process in $slots (a waiter table's occupants) + * + * @param list $slots + */ + public function notifyAll(array $slots, WakeEvent $event): void + { + foreach ($slots as $slot) { + $this->notify($slot, $event); + } + } + + /** + * Waits up to $seconds for events addressed to this process, then drains them + * + * @return list + */ + public function wait(float $seconds): array + { + $stream = $this->stream(); + $read = [$stream]; + $write = []; + $except = []; + $seconds = max($seconds, 0.0); + + $ready = @stream_select($read, $write, $except, (int) $seconds, (int) (fmod($seconds, 1.0) * 1_000_000)); + if ($ready === false || $ready === 0) { + return []; + } + + return $this->drain(); + } + + /** + * Reads every event queued for this process without blocking + * + * @return list + */ + public function drain(): array + { + $stream = $this->readers[$this->slot ?? -1] ?? null; + if ($stream === null) { + return []; + } + + while (($chunk = @fread($stream, WakeEvent::SIZE * 64)) !== false && $chunk !== '') { + $this->residue .= $chunk; + } + + $events = []; + while (\strlen($this->residue) >= WakeEvent::SIZE) { + $event = WakeEvent::fromBytes(substr($this->residue, 0, WakeEvent::SIZE)); + $this->residue = substr($this->residue, WakeEvent::SIZE); + if ($event !== null) { + $events[] = $event; + } + } + + return $events; + } + + /** + * Installs an inspector over the ONE place that writes to a socket + * + * Every byte this package sends between processes passes through here, which is what + * makes "the sockets never carry values" a testable statement instead of a promise. + * + * @param (callable(int, string): void)|null $observer Receives the target slot and the + * exact bytes about to be written + */ + public function observeWrites(?callable $observer): void + { + $this->observer = $observer; + } + + /** + * Whether this registry ever recovered its claim-table lock from a died owner + * + * The table is one word per entry, so a lock inherited through EOWNERDEAD guards state + * that cannot be half-written; recovery is reported rather than swallowed because a + * supervisor wants to know that a worker died holding a shared lock. + */ + public function wasLockRecovered(): bool + { + return $this->recoveredLock; + } + + /** + * Gives this process's slot back to the table (optional; a dead owner is recycled anyway) + */ + public function releaseSlot(): void + { + if ($this->slot === null || $this->ownerPid !== getmypid()) { + return; + } + $mutex = $this->mutexAddress(); + + $recovered = $this->arena->lockMutexAt($mutex); + + $this->arena->writeWord($this->entryAddress($this->slot), 0); + + $this->arena->unlockMutexAt($mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + $this->slot = null; + } + + /** + * Takes a free (or dead-owner) entry for $pid + */ + private function claim(int $pid): int + { + // Scanned WITHOUT the lock: single aligned word reads never tear, and posix_kill() + // has no business inside a critical section. Everything found here is re-verified + // under the lock before it is claimed + $candidates = []; + for ($slot = 0; $slot < $this->capacity; $slot++) { + $owner = $this->arena->readWord($this->entryAddress($slot)); + if ($owner === 0 || !self::isAlive($owner)) { + $candidates[] = [$slot, $owner]; + } + } + + $mutex = $this->mutexAddress(); + $claimed = null; + + $recovered = $this->arena->lockMutexAt($mutex); + + foreach ($candidates as [$slot, $owner]) { + if ($this->arena->readWord($this->entryAddress($slot)) === $owner) { + $this->arena->writeWord($this->entryAddress($slot), $pid); + $claimed = $slot; + + break; + } + } + + $this->arena->unlockMutexAt($mutex); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if ($claimed === null) { + throw IpcException::wakeRegistryFull($this->capacity); + } + if (!isset($this->writers[$claimed])) { + // The registry was not inherited: this process never got the descriptors + throw IpcException::wakeRegistryNotInherited(); + } + + return $claimed; + } + + /** + * The single choke point every cross-process byte of this package passes through + * + * @param resource $writer + */ + private function writeRecord(int $slot, WakeEvent $event, $writer): void + { + $bytes = $event->toBytes(); + \assert(\strlen($bytes) === WakeEvent::SIZE); + + if ($this->observer !== null) { + ($this->observer)($slot, $bytes); + } + + // Non-blocking on purpose: a full queue means the target already has more wakeups + // pending than it has processed, and one more would tell it nothing new + @fwrite($writer, $bytes); + } + + private function mutexAddress(): int + { + return $this->arena->readWord($this->address + self::WORD_MUTEX * 8); + } + + private function entryAddress(int $slot): int + { + return $this->address + (self::HEADER_WORDS + $slot) * 8; + } + + private static function isAlive(int $pid): bool + { + if ($pid <= 0 || !\function_exists('posix_kill')) { + return true; + } + + return posix_kill($pid, 0); + } +} diff --git a/src/ObjectPersistenceModule.php b/src/ObjectPersistenceModule.php index af32ce4..bf3d358 100644 --- a/src/ObjectPersistenceModule.php +++ b/src/ObjectPersistenceModule.php @@ -13,6 +13,8 @@ namespace Lisachenko\SharedData; +use Lisachenko\SharedData\Shm\Arena; +use ZEngine\Core; use ZEngine\EngineExtension\AbstractModule; use ZEngine\EngineExtension\ModuleDependency; use ZEngine\EngineExtension\ModuleInfoInterface; @@ -23,11 +25,27 @@ * * The module globals hold two machine words that survive the request boundary in the * worker process: - * [0] pointer to the persistent registry HashTable (0 until first boot) - * [1] layout version of the registry format (Registry::LAYOUT_VERSION, currently 3), + * [0] anchor of the persisted state (0 until first boot): the persistent registry + * HashTable in the default mode, the ARENA BASE in arena mode + * (PersistentStore::bootShared - the registry tables are then found through the + * arena's own roots directory, which is all a forked child can rely on) + * [1] layout version of the registry format (Registry::LAYOUT_VERSION, currently 5), * written when the registry is created and verified on every later boot - a worker * holding a registry from an older build is rejected instead of misread * + * Which of the two meanings applies is a property of the MODULE, never something to guess + * from the value: the two modes use different module names, so within one module globals[0] + * always means the same thing. + * + * ## Globals are read-only in forked children + * + * The globals of a persistent module live in ordinary process memory, so a fork gives every + * child a copy-on-write copy of that page. A child writing there does not corrupt anything - + * it does something worse, silently: the write becomes private to that child, and from then + * on parent and child disagree about where the persisted state is. Only the process that + * CREATES the state writes these words, before any worker exists; every later boot (later + * request, or any child) takes the recovery path and only reads them. + * * This is the same cross-request anchor mechanism as the counter demo in demo.php, * reduced to a single pointer slot: everything else persistent hangs off the registry. * @@ -90,8 +108,16 @@ public function getDisplayInfo(): array { $names = []; $objectCount = 0; + $store = PersistentStore::activeStore($this->getName()); $globals = $this->getGlobals(); - if ($globals !== null && $globals[0] !== 0) { + + if ($store !== null) { + // The live store knows which registry it holds - and in arena mode it is the + // ONLY thing that does: globals[0] is the arena base there, so reading it as a + // registry pointer would dereference the arena header as a hashtable + $names = $store->entryNames(); + $objectCount = $store->objectCount(); + } elseif ($globals !== null && $globals[0] !== 0 && !self::anchorsAnArena($globals[0])) { $registry = Registry::fromAddress($globals[0]); $names = $registry->names(); $objectCount = $registry->objectCount(); @@ -105,6 +131,18 @@ public function getDisplayInfo(): array ]; } + /** + * Whether the module anchor points at an ARENA rather than at a registry hashtable + * + * The last line of defence for a reporting path that runs without a live store: an + * arena starts with its magic word, a registry with an ordinary hashtable header, so + * one aligned load tells the two apart before anything is interpreted. + */ + private static function anchorsAnArena(int $anchor): bool + { + return (int) Core::pointerAtAddress('uint64_t *', $anchor)[0] === Arena::MAGIC; + } + public function moduleStartup(): void { } diff --git a/src/PersistedObject.php b/src/PersistedObject.php index fbca633..1a32306 100644 --- a/src/PersistedObject.php +++ b/src/PersistedObject.php @@ -40,6 +40,12 @@ final class PersistedObject * @param int $shares Number of registry ENTRIES whose members include this object * @param CData|null $metaTable HashTable* of the persisted metadata record (null until stored) * @param CData|null $arraysTable HashTable* of the allocation list itself (null until stored) + * @param bool $mutable Whether this object belongs to a SHARED MUTABLE graph: + * its slots are written by any process of the family + * through the stripe lock, and detach() must therefore + * never roll them back to the snapshot. Recorded in the + * registry (not in this process), so every worker reads + * the same role for the same object */ public function __construct( public int $address, @@ -51,6 +57,7 @@ public function __construct( public int $shares = 0, public ?CData $metaTable = null, public ?CData $arraysTable = null, + public bool $mutable = false, ) { } } diff --git a/src/PersistentStore.php b/src/PersistentStore.php index c54cb7f..1e1f23b 100644 --- a/src/PersistentStore.php +++ b/src/PersistentStore.php @@ -14,10 +14,15 @@ namespace Lisachenko\SharedData; use FFI\CData; +use Lisachenko\SharedData\Shm\Arena; +use Lisachenko\SharedData\Shm\ArenaAllocator; +use Lisachenko\SharedData\Shm\ArenaException; +use Lisachenko\SharedData\Shm\ArenaRegistryLayout; use ZEngine\Core; use ZEngine\Reflection\ReflectionValue; use ZEngine\Type\ObjectEntry; use ZEngine\Type\PersistentObjectFactory; +use ZEngine\Type\TypeOperationException; /** * PHP objects that survive the request boundary (per worker process) @@ -48,6 +53,29 @@ */ final class PersistentStore { + /** + * Module the arena-backed mode anchors itself in, kept apart from the default one so + * globals[0] always means the same thing within a module (a registry address there, an + * arena base here) + */ + public const string SHARED_MODULE = 'shared_arena'; + + /** + * What a shared object's `handle` field is set to once this process has registered it + * + * The object-store handle is per-process state that happens to sit inside the shared + * struct, and forked children hand out IDENTICAL handles for different objects, because + * they inherit one free list (docs/shared-memory-model.md, §3). Every process therefore + * keeps its own handle in the side table and overwrites the shared field with a value the + * store can never produce - a saturated uint32 would need four billion live buckets - so + * that any code trusting the shared field fails loudly instead of recycling a slot that + * belongs to a sibling's object. + * + * The visible consequence: `spl_object_id()` of a shared object returns this number in + * every process. It is not an identity; PersistentStore::sharedIdOf() is. + */ + public const int SHARED_HANDLE_SENTINEL = 0xFFFFFFFF; + /** * Stores booted during this request, keyed by module name (request-scoped: PHP * statics reset per request, exactly like the shutdown functions the stores arm) @@ -64,23 +92,36 @@ final class PersistentStore private array $instances = []; /** - * Object-store handles held during this request, keyed by persistent clone ADDRESS + * The per-process fields of every shared object this request registered, keyed by ADDRESS * - * Keying by address rather than by entry is what keeps a shared object registered - * exactly once per request, no matter how many entries reach it. + * Keying by address rather than by entry is what keeps a shared object registered exactly + * once per request, no matter how many entries reach it - and in arena mode it is the only + * key that means the same thing in two processes at all. + */ + private SideTable $sideTable; + + /** + * Mutation handles minted for this request, keyed by address (their slot views are bound + * once per process, so handing the same handle back is both cheaper and required) * - * @var array + * @var array */ - private array $handles = []; + private array $mutableHandles = []; private bool $attached = false; private bool $shutdownArmed = false; - private function __construct(Registry $registry) + /** + * Slots repaired at detach because they held a pointer into some process's private heap + */ + private int $repairedSlots = 0; + + private function __construct(Registry $registry, private readonly ?ArenaAllocator $allocator = null) { $this->registry = $registry; - $this->persister = new Persister(); + $this->persister = new Persister($allocator); + $this->sideTable = new SideTable(); } /** @@ -123,6 +164,139 @@ public static function boot(string $moduleName = 'shared_objects'): self return $store; } + /** + * Boots a store whose persisted state lives in a FORK-SHARED ARENA + * + * The opt-in counterpart of boot(): same API, same frozen semantics, but every block + * the store mints - registry tables, object clones, snapshots, strings, sealed arrays - + * comes out of $arena, so a graph persisted here is readable by every process of the + * worker family at the same addresses. Nothing about the default path changes; the two + * modes even use different module names, so a worker may run both side by side. + * + * Call order matters: + * + * 1. the parent maps the arena and boots this store BEFORE forking - the mapping, the + * registry tables and their roots-directory entries must exist at fork time; + * 2. every child boots the same store again to get its own request-scoped view. A + * child takes the RECOVERY path (module globals are inherited and non-zero) and + * therefore never writes the module globals: that page is copy-on-write, so a write + * would silently become private to the child and desynchronize the family. + * + * ## The engine state inside a shared object, and where it actually lives + * + * Sharing the memory is one thing; sharing the ENGINE STATE inside a `zend_object` is + * another, and three of its fields are per-process by nature. They are kept in a + * per-process SideTable, and the shared struct is treated accordingly: + * + * - **`handle`** - the object-store slot. Forked children inherit one free list and hand + * out identical handles, so the shared field is overwritten with + * SHARED_HANDLE_SENTINEL after every registration and the real handle lives in the side + * table. `spl_object_id()` on a shared object is therefore meaningless *by + * construction*; sharedIdOf() returns the arena address, which is the identity every + * process agrees on; + * - **`ce`** - rebound per process at attach and recorded in the side table; the shared + * field is advisory. It is only fork-stable for classes loaded BEFORE the fork + * (opcache.preload, or simply touching the class), which remains a requirement: a class + * first autoloaded inside one worker lands at an address no sibling can follow; + * - **`properties`** - the dynamic-property cache, which engine C code writes on + * read-shaped operations (`var_dump()`, `get_object_vars()`, `json_encode()`, + * `(array)`, `serialize()`, `debug_zval_dump()`, `ReflectionObject`). It is forced NULL + * at attach and never dereferenced in arena mode - a non-null value there may be a + * pointer into a sibling's request heap. Call scrubProperties() after any of those + * operations, or use inspect() which does it for you. + * + * ## Frozen by default, mutable on request + * + * A graph persisted through persist() keeps frozen semantics: request-time mutations are + * rolled back at request end. Pass `mutable: true` to opt one graph into SHARED MUTATION - + * no rollback, and a synchronized write API (mutableHandle()) that takes the object's + * stripe lock, interns strings into the arena and refuses anything a sibling could not + * follow. + * + * @param Arena $arena Fork-shared arena, created before any fork + * @param ArenaRegistryLayout|null $layout Table capacities; only read when the + * registry is created (the first boot) + * @param string $moduleName Persistent module to anchor the arena in + */ + public static function bootShared( + Arena $arena, + ?ArenaRegistryLayout $layout = null, + string $moduleName = self::SHARED_MODULE, + ): self { + $module = new ObjectPersistenceModule($moduleName); + if (!$module->isModuleRegistered()) { + $module->register(); + $module->startup(); + } + + $globals = $module->getGlobals(); + if ($globals === null) { + throw new \RuntimeException('Persistent module globals are not available'); + } + $allocator = new ArenaAllocator($arena); + + if ($globals[0] === 0) { + [$registry, $base] = Registry::createInArena($allocator, $layout); + // The ONLY globals write of arena mode, and it happens in the process that + // owns the arena, before any worker exists + $globals[0] = $base; + $globals[1] = Registry::LAYOUT_VERSION; + } else { + if ($globals[1] !== Registry::LAYOUT_VERSION) { + throw new \RuntimeException(sprintf( + 'Persistent registry of module %s uses layout version %d, this build expects %d; ' . + 'restart the worker to rebuild the persisted state', + $moduleName, + $globals[1], + Registry::LAYOUT_VERSION, + )); + } + if ($globals[0] !== $arena->baseAddress()) { + throw ArenaException::foreignArena($globals[0], $arena->baseAddress()); + } + // The mapping is inherited, not re-created: prove it is still an arena of this + // layout before any offset inside it is trusted + $arena->assertIntact(); + + $registry = Registry::fromArena($allocator); + } + + // Arms the last line of defence before any free(): no path of this process may hand a + // block of THIS mapping back to an allocator, because there is no allocator that owns + // it - and in a forked child it would be memory the whole family is still reading + Reclaimer::protect($arena); + + $store = new self($registry, $allocator); + + self::$activeStores[$moduleName] = $store; + + return $store; + } + + /** + * The store booted for $moduleName during this request, if any + * + * How a module reaches its own state without interpreting its globals: in arena mode + * globals[0] is an ARENA BASE, and reading it as a registry pointer would dereference + * the arena header as a hashtable. The live store knows which registry it holds and how + * it was built, so anything that wants to REPORT on the state (phpinfo(), diagnostics) + * asks here first. + */ + public static function activeStore(string $moduleName): ?self + { + return self::$activeStores[$moduleName] ?? null; + } + + /** + * Storage keys of every graph this store holds + * + * @return list + */ + public function entryNames(): array + { + return $this->registry->names(); + } + /** * Detaches every store booted during this request (idempotent per store) * @@ -155,14 +329,30 @@ public static function detachActiveStores(): void * request that still holds instances of objects only the previous graph referenced * gets a RuntimeException instead of freed memory under its feet. * + * ## Opting into shared mutation + * + * With `mutable: true` (arena mode only) the graph keeps everything that makes a + * persistent clone safe - the PIN_BASELINE refcount pin, GC_PERSISTENT|GC_NOT_COLLECTABLE, + * bare non-refcounted slot payloads and sealed immutable arrays - but gives up FROZEN + * SEMANTICS: detach() never rolls its slots back, because a memcpy of a request-old + * snapshot over a table three other workers are writing would destroy their state. Each + * object is guarded by the stripe mutex its address hashes to, and mutableHandle() is the + * synchronized way to write it (docs/shared-memory-model.md, §2). + * + * The role is recorded in the registry, not in this process, so every worker attaching the + * same address later applies the same lifecycle. One object cannot belong to both a frozen + * and a mutable graph - the two lifecycles contradict each other - and such a persist is + * refused rather than silently resolved. + * * @template T of object * * @param class-string $className Storage key; the object must be an instance of it * @param T $object + * @param bool $mutable Persist as a SHARED MUTABLE graph (arena mode only) * * @return T The canonical persistent instance */ - public function persist(string $className, object $object): object + public function persist(string $className, object $object, bool $mutable = false): object { if (!$object instanceof $className) { throw new \InvalidArgumentException(sprintf( @@ -171,6 +361,9 @@ public function persist(string $className, object $object): object get_class($object), )); } + if ($mutable && $this->allocator === null) { + throw SharedMutationException::requiresSharedMode($className); + } $this->attach(); $entry = $this->persister->persistObject( @@ -178,6 +371,19 @@ public function persist(string $className, object $object): object fn (int $address): ?PersistedObject => $this->registry->findObject($address), ); + // Members the graph REACHED instead of creating are already registered with a role of + // their own; adopting them into the opposite one would change the lifecycle of an + // object another entry - possibly another process - is relying on + foreach ($entry->members as $address) { + $existing = $this->registry->findObject($address); + if ($existing !== null && $existing->mutable !== $mutable) { + throw SharedMutationException::modeConflict($className, $existing->className, $mutable); + } + } + foreach ($entry->created as $created) { + $created->mutable = $mutable; + } + // Hydrated BEFORE the upsert overwrites the record, and released AFTER the new // members were share-incremented: an object belonging to both generations must // never transit through a share count of zero. Members the new graph keeps @@ -285,6 +491,269 @@ public function get(string $className): ?object return $instance; } + /** + * Address of an entry's canonical root clone - the eight bytes that travel between workers + * + * In arena mode this is an address inside the shared mapping, and it means the very same + * object in every process of the worker family. Handing it to a sibling over a socket + * (as a fixed-size record, never a serialized value) and calling attachObject() there is + * the whole cross-process exchange protocol: no encoding, no copy, one pointer. + * + * Handles are NOT a substitute: forked children inherit the same object-store free list + * and hand out identical handle numbers, so handles collide by construction. The address + * is the only stable identity across processes. + * + * @param class-string $className + */ + public function addressOf(string $className): ?int + { + $entry = $this->registry->findEntry($className); + + return $entry?->root(); + } + + /** + * Address of an instance, if THIS store's registry is the one that shares it + * + * The predicate behind every "may this value cross a worker boundary?" decision: an + * ordinary request object, a persistent clone minted by another registry and an object + * whose entry was dropped all answer null, and only a null-free answer is an address a + * sibling process may follow. Note what is deliberately not used here - the object's + * handle, which forked children hand out identically for different objects (EPIC #15, + * correction #4); identity in the shared area is the ARENA ADDRESS and nothing else. + * + * @return int|null Address of the shared zend_object, or null when it is not shared + */ + public function addressOfInstance(object $instance): ?int + { + $value = new ReflectionValue($instance); + + try { + $address = Core::addressOf($value->getRawObject()); + } finally { + $value->release(); + } + + return $this->registry->findObject($address) !== null ? $address : null; + } + + /** + * Materializes the persistent object living at $address for the current request + * + * The receiving half of the exchange above: the object is looked up in the registry + * (which is what proves the address is one of ours), rebound to this process's class + * entry and registered in this request's object store if it is not already. + * + * @param int $address Address obtained from addressOf() in this or another process + */ + public function attachObject(int $address): object + { + $this->attach(); + + $object = $this->registry->findObject($address); + if ($object === null) { + throw new \RuntimeException(sprintf( + 'No persistent object is registered at address 0x%x; only addresses handed out by ' . + 'addressOf() of a store sharing this registry can be attached', + $address, + )); + } + if (!$this->sideTable->has($address)) { + $this->rebindClassEntry($object); + $this->register($address, $object->object); + } + + return self::instanceOf($object->object); + } + + /** + * The stable cross-process identity of a shared instance: its ARENA ADDRESS + * + * `spl_object_id()` cannot play this role and never could. It reads the object-store handle + * out of the shared struct, which is per-process state: forked children inherit one free + * list and are handed identical handles for different objects, and this store overwrites + * the field with SHARED_HANDLE_SENTINEL precisely so that nobody builds identity on it. + * The address, by contrast, means the same object in every process of the family - it is + * what travels over a socket, what the registry keys by, and what a sibling attaches. + * + * @throws SharedMutationException When the instance is not a shared object of this store + */ + public function sharedIdOf(object $instance): int + { + return $this->addressOfInstance($instance) + ?? throw SharedMutationException::notShared(get_class($instance)); + } + + /** + * The synchronized read/write API for one object of a SHARED MUTABLE graph + * + * Handles are cached per address for the request: their slot views are bound once per + * process, and rebinding them per call would allocate inside what is meant to be a hot + * path (and, worse, invite a CData creation next to a critical section). + * + * @param object|int $target The shared instance, or its arena address + */ + public function mutableHandle(object|int $target): SharedObjectHandle + { + $this->attach(); + + $address = \is_int($target) + ? $target + : ($this->addressOfInstance($target) ?? throw SharedMutationException::notShared(get_class($target))); + + if (isset($this->mutableHandles[$address])) { + return $this->mutableHandles[$address]; + } + + $object = $this->registry->findObject($address); + if ($object === null) { + throw SharedMutationException::notShared(sprintf('object at 0x%x', $address)); + } + if ($this->allocator === null) { + throw SharedMutationException::requiresSharedMode($object->className); + } + if (!$object->mutable) { + throw SharedMutationException::notMutable($object->className, $address); + } + if (!$this->sideTable->has($address)) { + $this->rebindClassEntry($object); + $this->register($address, $object->object); + } + $classEntry = $this->sideTable->classEntryOf($address); + \assert($classEntry !== null); + + return $this->mutableHandles[$address] = new SharedObjectHandle( + $this, + $this->allocator, + $object->object, + $classEntry, + $address, + $object->className, + ); + } + + /** + * The object-store handle THIS process holds for a shared object, if it registered it + * + * The value the shared struct deliberately no longer carries. Two processes routinely hold + * different numbers for the same object - and, because they inherit one free list, the + * same number for different objects - which is the whole reason it lives here. + */ + public function processHandleOf(int $address): ?int + { + return $this->sideTable->handleOf($address); + } + + /** + * Whether this store's state lives in a fork-shared arena + */ + public function isShared(): bool + { + return $this->allocator !== null; + } + + /** + * Whether the object at this address was persisted as a shared MUTABLE one + */ + public function isMutable(object|int $target): bool + { + $address = \is_int($target) ? $target : $this->addressOfInstance($target); + + return $address !== null && $this->registry->findObject($address)?->mutable === true; + } + + /** + * Clears the dynamic-property cache engine C code left inside a shared object + * + * `var_dump()`, `get_object_vars()`, `json_encode()`, `(array)`, `serialize()`, + * `debug_zval_dump()` and `ReflectionObject` all make the engine rebuild the property bag + * and CACHE it in the object's `properties` field - a pointer into the request heap of + * whichever process ran the operation, deposited in memory every process reads. A sibling + * that follows it dereferences foreign memory; this is the one field of a shared object + * that must never be trusted (docs/shared-memory-model.md, §3). + * + * So the pointer is dropped WITHOUT being dereferenced: no refcount is read, no table is + * destroyed. What that costs is one request-heap table left to the request allocator, + * which reclaims it at request end anyway; what it buys is that nothing here can ever + * touch another process's heap. Call it after any of the operations above - or use + * inspect(), which brackets the call for you. + * + * @param object|int $target Shared instance, or its arena address + * + * @return bool Whether a cached table was actually found and dropped + */ + public function scrubProperties(object|int $target): bool + { + $address = \is_int($target) ? $target : $this->addressOfInstance($target); + $object = $address === null ? null : $this->registry->findObject($address); + if ($object === null) { + return false; + } + $objectEntry = ObjectEntry::fromCData($object->object); + if ($objectEntry->getDynamicPropertiesPointer() === null) { + return false; + } + $objectEntry->setDynamicPropertiesPointer(null); + + return true; + } + + /** + * Runs an inspection of a shared object and scrubs whatever it cached inside it + * + * The safe way to `var_dump()` or `json_encode()` a shared instance: the cache the engine + * writes is dropped in the same process that caused it, before any sibling can follow the + * pointer. + * + * @template TResult + * + * @param callable(object): TResult $reader Receives the attached instance + * + * @return TResult + */ + public function inspect(object|int $target, callable $reader): mixed + { + $address = \is_int($target) ? $target : $this->sharedIdOf($target); + $instance = $this->attachObject($address); + + try { + return $reader($instance); + } finally { + $this->scrubProperties($address); + } + } + + /** + * Raw value of a shared object's `properties` field: 0 when it is NULL, as it must be + * + * Diagnostics only, and deliberately never dereferenced - the point of this accessor is + * to observe that the field is clean without touching what it may be pointing at. + */ + public function dynamicPropertiesAddressOf(int $address): int + { + $object = $this->registry->findObject($address); + if ($object === null) { + return 0; + } + $pointer = ObjectEntry::fromCData($object->object)->getDynamicPropertiesPointer(); + + return $pointer === null ? 0 : Core::addressOf($pointer); + } + + /** + * Number of slots this request repaired at detach because they held foreign pointers + * + * A direct `$object->name = 'x'` on a shared mutable object stores a REQUEST-HEAP string + * pointer in shared memory (see SharedObjectHandle for why the engine gives no hook to + * prevent it). Such slots are restored from the frozen image at detach instead of being + * left behind for a sibling to follow; this counter is how a test - or a worker's + * diagnostics - notices that it happened. + */ + public function repairedSlotCount(): int + { + return $this->repairedSlots; + } + /** * @param class-string $className */ @@ -312,6 +781,16 @@ public function objectCount(): int * refcount pin and hides the object from the object-store teardown. Runs * automatically as a shutdown function; public so worker loops and tests can cycle * attach()/detach() manually. + * + * ## Role-aware: a shared mutable graph is never rolled back + * + * The snapshot rollback IS the frozen semantics, and it is exactly wrong for a graph that + * opted into sharing: memcpy'ing a request-old image over slots that three other workers + * are writing would destroy their state with no diagnostic whatsoever - the writes would + * simply be gone. So a mutable object keeps everything it has, and only slots holding a + * pointer OUTSIDE the arena are repaired from the frozen image, because those are not + * shared state at all but the residue of an unsynchronized direct write (see + * SharedObjectHandle). Frozen graphs, in either mode, roll back byte for byte as before. */ public function detach(): void { @@ -320,29 +799,31 @@ public function detach(): void } // Drop our own references first so only foreign references remain in the count - $this->instances = []; - - /** @var list $objects */ - $objects = iterator_to_array($this->registry->allObjects(), false); + $this->instances = []; + $this->mutableHandles = []; + + // Exactly the objects THIS process registered this request, never the whole + // registry. In frozen mode the two are the same set (attach() registers every + // object there is, and a dropped object leaves both). In arena mode they are not: + // a sibling worker may have persisted objects after this process attached, and + // those carry a class entry this process never rebound - rolling them back would + // dereference another process's zend_class_entry pointer. + $objects = []; + foreach ($this->sideTable->addresses() as $address) { + $object = $this->registry->findObject($address); + if ($object !== null) { + $objects[] = $object; + } + } foreach ($objects as $object) { - $this->restoreSnapshot($object->object, $object->snapshot); - - $objectEntry = ObjectEntry::fromCData($object->object); - - // Release the request-allocated properties hashtable rebuilt by - // get_object_vars()/var_dump()/casts, it would dangle next request. - // Mirrors zend_array_release(): drop our reference, and let the - // engine dismantle the table through its own allocator at zero - $dynamicProperties = $objectEntry->getDynamicPropertiesPointer(); - if ($dynamicProperties !== null) { - $gcHeader = $dynamicProperties->gc; - $gcHeader->refcount = $gcHeader->refcount - 1; - if ($gcHeader->refcount === 0) { - Core::call('rc_dtor_func', Core::cast('zend_refcounted *', $dynamicProperties)); - } - $objectEntry->setDynamicPropertiesPointer(null); + if ($object->mutable) { + $this->repairForeignPayloads($object); + } else { + $this->restoreSnapshot($object->object, $object->snapshot); } + + $this->releaseDynamicProperties($object); } // Pins are re-baselined only after EVERY rollback is done: a slot the request @@ -352,11 +833,15 @@ public function detach(): void $object->object->gc->refcount = PersistentObjectFactory::PIN_BASELINE; } - foreach ($this->handles as $handle) { - Core::$executor->objectStore->recycle($handle); + foreach ($this->sideTable->addresses() as $address) { + $object = $this->registry->findObject($address); + $handle = $this->sideTable->handleOf($address); + if ($object !== null && $handle !== null) { + $this->releaseHandle($object->object, $handle); + } } - $this->handles = []; + $this->sideTable->clear(); $this->attached = false; } @@ -388,6 +873,16 @@ private function guardedCandidates(string $className, PersistedEntry $entry, arr } foreach ($candidates as $candidate) { + // The alias predicate is a SINGLE-PROCESS instrument and is disabled for shared + // graphs. A refcount in the arena is written by every worker that ever copied the + // value, so it saturates above the pin baseline and stays there: a sibling holding + // an alias would make every drop fail, and a sibling that exited without detaching + // would make it succeed while its pages are still mapped. It also protects nothing + // there - an arena block is never handed back (Registry::removeObject), so dropping + // a shared entry unlinks bookkeeping and frees no memory at all + if ($this->allocator !== null) { + continue; + } // Userland copies of an object zval addref even a pinned persistent clone, so // anything off the baseline means the request can still reach this object if ($candidate->object->gc->refcount === PersistentObjectFactory::PIN_BASELINE) { @@ -450,7 +945,7 @@ private function releaseEntry(string $className, PersistedEntry $entry, array $c private function materialize(string $name, PersistedEntry $entry): object { foreach ($entry->members as $address) { - if (isset($this->handles[$address])) { + if ($this->sideTable->has($address)) { continue; } $object = $this->registry->findObject($address); @@ -467,11 +962,32 @@ private function materialize(string $name, PersistedEntry $entry): object /** * Gives one persistent clone a fresh object-store handle for this request + * + * The handle z-engine hands back is PER-PROCESS state, so it goes into the side table - + * and in arena mode the field inside the shared struct is immediately overwritten with + * SHARED_HANDLE_SENTINEL. Not writing it at all is not an option: `zend_objects_store_put` + * writes it, and two children of one parent are handed the SAME number for different + * objects (they inherit one free list), so whatever is left in there would be a lie for at + * least one of them. A value the store can never produce makes that lie unusable. + * + * The dynamic-property cache is cleared in the same breath, for the same reason a sibling + * must never dereference it (see scrubProperties()). */ private function register(int $address, CData $object): void { - $this->handles[$address] = Core::$executor->objectStore->put($object); - $object->gc->refcount = PersistentObjectFactory::PIN_BASELINE; + $objectEntry = ObjectEntry::fromCData($object); + $handle = $objectEntry->register(); + + $classEntry = $object->ce; + \assert($classEntry !== null); + $this->sideTable->put($address, $handle, $classEntry); + + if ($this->allocator !== null) { + $object->handle = self::SHARED_HANDLE_SENTINEL; + $objectEntry->setDynamicPropertiesPointer(null); + } + + $object->gc->refcount = PersistentObjectFactory::PIN_BASELINE; } /** @@ -482,10 +998,146 @@ private function register(int $address, CData $object): void */ private function unregister(int $address): void { - if (isset($this->handles[$address])) { - Core::$executor->objectStore->recycle($this->handles[$address]); - unset($this->handles[$address]); + $handle = $this->sideTable->handleOf($address); + if ($handle === null) { + return; + } + $object = $this->registry->findObject($address); + if ($object !== null) { + $this->releaseHandle($object->object, $handle); + } + $this->sideTable->forget($address); + unset($this->mutableHandles[$address]); + } + + /** + * Hands one object-store slot back, using the handle THIS process was given + * + * z-engine reads the handle out of the object and verifies that the slot really holds this + * object before recycling it, which is exactly the guard that matters here: the shared + * field carries a sentinel, so the side-table handle is put back for the duration of the + * call and the sentinel is restored afterwards. A refusal is not an error - it means the + * slot was meanwhile reused, and refusing to recycle somebody else's slot is the guard + * doing its job. + * + * That restore is the ONE moment the shared field is not the sentinel, and it is visible + * to siblings: a process calling spl_object_id() on a shared object while another one is + * detaching may see that other process's handle instead. The window is a few instructions + * wide and cannot be locked away - recycling a store slot is an engine call, and engine + * calls are forbidden under an arena mutex. It is harmless because nothing in this package + * ever reads that field (every path goes through the side table), and it is the reason the + * sentinel is documented as "do not trust this field" rather than "this field is always + * the sentinel". Identity is sharedIdOf(), always. + */ + private function releaseHandle(CData $object, int $handle): void + { + $object->handle = $handle; + + try { + ObjectEntry::fromCData($object)->unregister(); + } catch (TypeOperationException) { + // The slot no longer holds this object: leave it alone + } finally { + if ($this->allocator !== null) { + $object->handle = self::SHARED_HANDLE_SENTINEL; + } + } + } + + /** + * Restores the slots of a mutable object that hold a pointer into somebody's private heap + * + * The only rollback a shared mutable graph ever gets, and it is not about freshness: a + * slot pointing outside the arena is the residue of a direct `$object->prop = 'x'`, where + * the engine stored a REQUEST-HEAP string, array or object pointer inside shared memory. + * Leaving it there would hand every sibling - and every later request of this worker - a + * pointer into memory that is about to be reclaimed. The frozen image is a valid arena + * payload by construction, so it is what the slot goes back to. + * + * Scalars are untouched: they carry no pointer, so an unsynchronized scalar write is + * merely racy, and racy is what its author asked for. + */ + private function repairForeignPayloads(PersistedObject $object): void + { + $arena = $this->allocator?->arena(); + if ($arena === null) { + return; + } + $count = (int) $object->object->ce->default_properties_count; + if ($count === 0) { + return; + } + $zvalSize = Core::sizeof(Core::type('zval')); + $tableBase = Core::cast('zval *', Core::addr($object->object->properties_table[0])); + $frozen = Core::cast('zval *', $object->snapshot); + $stripe = $arena->stripeFor($object->address); + + for ($index = 0; $index < $count; $index++) { + $slot = Core::addr($tableBase[$index]); + $type = $slot->u1->v->type; + if ( + $type !== ReflectionValue::IS_STRING + && $type !== ReflectionValue::IS_ARRAY + && $type !== ReflectionValue::IS_OBJECT + ) { + continue; + } + $payload = (int) Core::cast('uint64_t *', $slot)[0]; + if ($arena->contains($payload)) { + continue; + } + // Not every non-arena pointer is foreign: a string the source object had already + // interned permanently (a compile-time literal, an opcache SHM string) is kept by + // pointer at persist time and lives in memory every forked process shares + // identically. The frozen image says which those are - it holds exactly what + // persist() decided, so a slot still equal to it was never written by anybody + if ($payload === (int) Core::cast('uint64_t *', Core::addr($frozen[$index]))[0]) { + continue; + } + + // Every CData is created BEFORE the lock: allocation under an arena mutex is + // forbidden, and a critical section here is one 16-byte memcpy + $live = Core::cast('char *', $slot); + $frozenSlot = Core::cast('char *', Core::addr($frozen[$index])); + + $arena->lockStripe($stripe); + Core::memcpy($live, $frozenSlot, $zvalSize); + $arena->unlockStripe($stripe); + + $this->repairedSlots++; + } + } + + /** + * Releases (frozen mode) or simply drops (shared mode) the dynamic-property cache + * + * In a single-process registry the table was built by THIS request and releasing it is + * both correct and tidy. In the arena it may have been built by any process of the family, + * and reading its refcount would already be a dereference of foreign memory - so the + * pointer is dropped unread, and the request allocator that owns it reclaims it with the + * request. + */ + private function releaseDynamicProperties(PersistedObject $object): void + { + $objectEntry = ObjectEntry::fromCData($object->object); + $dynamicProperties = $objectEntry->getDynamicPropertiesPointer(); + if ($dynamicProperties === null) { + return; + } + if ($this->allocator !== null) { + $objectEntry->setDynamicPropertiesPointer(null); + + return; + } + + // Mirrors zend_array_release(): drop our reference, and let the engine dismantle the + // table through its own allocator at zero + $gcHeader = $dynamicProperties->gc; + $gcHeader->refcount = $gcHeader->refcount - 1; + if ($gcHeader->refcount === 0) { + Core::call('rc_dtor_func', Core::cast('zend_refcounted *', $dynamicProperties)); } + $objectEntry->setDynamicPropertiesPointer(null); } /** @@ -534,7 +1186,11 @@ private function rebindClassEntry(PersistedObject $object): void ); } + // The shared field is advisory: it is written because the engine reads it on every + // property access, and recorded per process because only THIS process's pointer may + // ever be handed to an engine call (docs/shared-memory-model.md, §3) $object->object->ce = $classEntry; + $this->sideTable->bindClassEntry($object->address, $classEntry); } /** diff --git a/src/Persister.php b/src/Persister.php index 444582a..6b27521 100644 --- a/src/Persister.php +++ b/src/Persister.php @@ -14,6 +14,7 @@ namespace Lisachenko\SharedData; use FFI\CData; +use Lisachenko\SharedData\Shm\ArenaAllocator; use ZEngine\Core; use ZEngine\Reflection\ReflectionValue; use ZEngine\Type\PersistentHashTable; @@ -56,9 +57,29 @@ * After conversion a byte snapshot of every NEWLY created properties_table is taken: * detach() restores them at request shutdown, which gives persisted graphs frozen * semantics (request-time mutations do not survive - see README). + * + * ## Where the converted graph lives + * + * Given an ArenaAllocator, every block this converter mints - object clones, snapshot + * buffers, interned strings, sealed array tables and their keys - comes out of the + * fork-shared arena instead of the process heap, and the resulting graph is readable by + * every process of the worker family at the same addresses. Without one (the default), + * the conversion is byte for byte the malloc-backed one it has always been. + * + * There is no half-way: a single malloc-backed block inside an otherwise shared graph is + * a pointer a sibling process cannot follow, which is why the allocator is threaded + * through EVERY minting call below rather than through some of them. */ final class Persister { + /** + * @param ArenaAllocator|null $allocator Source of every persistent block minted here; + * null keeps the malloc-backed frozen-mode path + */ + public function __construct(private readonly ?ArenaAllocator $allocator = null) + { + } + /** * Cycle/diamond map of the graph walk: source zend_object address => persistent clone * @@ -123,7 +144,7 @@ public function persistObject(object $source, callable $lookup): PersistedEntry $created[] = new PersistedObject( $address, $clone['object'], - self::snapshotProperties($clone['object']), + $this->snapshotProperties($clone['object']), $clone['className'], $clone['signature'], $this->arraysByOwner[$address] ?? [], @@ -150,6 +171,23 @@ public static function computeSignature(CData $classType): string return sha1(implode('|', $parts)); } + /** + * Property slots of a class entry, in properties_table order + * + * The same mapping the conversion below walks, exposed because writing one property of a + * shared object means writing one SLOT of it: the mutation API has to resolve a name to a + * slot index exactly as the persister does, and against the class entry of the process + * doing the writing. + * + * @param CData $classType zend_class_entry* of the process that is asking + * + * @return array slot index => declared property name + */ + public static function propertySlots(CData $classType): array + { + return self::declaredPropertyNames($classType); + } + /** * Converts one graph object, or returns the clone minted for it earlier * @@ -178,7 +216,7 @@ private function persistGraphObject(CData $rawObject, object $instance, string $ $this->assertPersistableObject($rawObject, $instance, $path); - $clone = PersistentObjectFactory::persistentClone($rawObject); + $clone = PersistentObjectFactory::persistentClone($rawObject, $this->allocator); $cloneAddress = Core::addressOf($clone); // Registered BEFORE any slot is converted - a self-reference discovered below @@ -265,19 +303,32 @@ private function assertPersistableObject(CData $rawObject, object $instance, str /** * Captures the frozen byte image of an object's finished properties_table */ - private static function snapshotProperties(CData $object): CData + private function snapshotProperties(CData $object): CData { - $tableSize = $object->ce->default_properties_count * Core::sizeof(Core::type('zval')); - if ($tableSize > 0) { - $snapshot = Core::trackedNew("char[{$tableSize}]", true); + // Even a property-less object needs a non-null anchor buffer + $tableSize = max($object->ce->default_properties_count * Core::sizeof(Core::type('zval')), 1); + $snapshot = $this->allocateBuffer($tableSize); + + if ($object->ce->default_properties_count > 0) { $tableBase = Core::cast('char *', Core::addr($object->properties_table[0])); Core::memcpy($snapshot, $tableBase, $tableSize); - } else { - // Even a property-less object needs a non-null anchor buffer - $snapshot = Core::trackedNew('char[1]', true); } - return Core::cast('char *', $snapshot); + return $snapshot; + } + + /** + * Allocates a raw persistent byte buffer, from the arena when there is one + * + * @return CData char* to $size zeroed bytes + */ + private function allocateBuffer(int $size): CData + { + if ($this->allocator === null) { + return Core::cast('char *', Core::trackedNew("char[{$size}]", true)); + } + + return Core::pointerAtAddress('char *', $this->allocator->allocate($size)); } /** @@ -398,7 +449,7 @@ private function persistStringSlot(CData $slot, string $path): void $string = StringEntry::fromCData($slot->value->str); if (!$string->isPermanent()) { - $interned = StringEntry::persistentInterned($string->getStringValue()); + $interned = StringEntry::persistentInterned($string->getStringValue(), $this->allocator); $slot->value->str = $interned->getRawValue(); } // Interned/permanent payloads live in non-refcounted slots (bare IS_STRING) @@ -410,7 +461,9 @@ private function persistStringSlot(CData $slot, string $path): void */ private function persistArray(CData $sourceArray, string $path): PersistentHashTable { - $target = new PersistentHashTable(); + // An arena table is pre-sized for the elements it will receive and can never be + // grown afterwards; the source array knows exactly how many that is + $target = $this->allocator?->createTable($sourceArray->nNumOfElements) ?? new PersistentHashTable(); // Ownership is recorded up front: elements converted below may mint nested tables, // and they all belong to the same object - the one whose slot started this array @@ -442,10 +495,14 @@ private function persistArray(CData $sourceArray, string $path): PersistentHashT $this->persistValueInPlace(Core::addr($element), "{$path}[{$keyLabel}]"); $elementValue = ReflectionValue::fromValueEntry(Core::addr($element)); - if ($stringKey !== null) { + if ($stringKey === null) { + $target->addIndex($intKey, $elementValue); + } elseif ($this->allocator === null) { $target->add($stringKey, $elementValue); } else { - $target->addIndex($intKey, $elementValue); + // Keys are part of the shared payload: minted in the arena as well, or a + // sibling process would follow the bucket key into foreign memory + $target->addInterned(StringEntry::persistentInterned($stringKey, $this->allocator), $elementValue); } } Core::free($element); diff --git a/src/Reclaimer.php b/src/Reclaimer.php index 8f40663..029fc41 100644 --- a/src/Reclaimer.php +++ b/src/Reclaimer.php @@ -14,6 +14,8 @@ namespace Lisachenko\SharedData; use FFI\CData; +use Lisachenko\SharedData\Shm\Arena; +use Lisachenko\SharedData\Shm\ArenaException; use ZEngine\Core; use ZEngine\Type\PersistentHashTable; @@ -72,9 +74,56 @@ * payloads cannot be checked that way (immutable arrays live in NON-refcounted zvals, so * copies leave no trace), which is why the README states that copies of a dropped entry's * arrays taken earlier in the same request must not be used after drop() returns. + * + * ## What must never reach this class: arena memory + * + * Everything above is about MALLOC memory owned by one process. A block in the fork-shared + * arena is the opposite of that in every respect - it is bump-allocated, it is read by every + * worker of the family, and it has no allocator that could take it back. The registry + * already routes arena-backed state past every call below, but that is a decision made at one + * call site, and a free of shared memory from a child is not the kind of mistake that + * announces itself: it corrupts the process heap of whoever calls it and leaves the siblings + * reading memory nobody owns. + * + * So the refusal lives HERE, at the last line before the free, and it is armed by the arena + * itself: PersistentStore::bootShared() protects its arena for the request, and every free + * path in this class then refuses any block inside it - in the creating process and in every + * child alike. */ final class Reclaimer { + /** + * Arenas whose blocks must never be freed by this process, keyed by base address + * + * Request-scoped by nature (a PHP static), which is exactly right: each request boots its + * store again and re-arms the guard for the mapping it is actually using. + * + * @var array + */ + private static array $protected = []; + + /** + * Arms the guard for one arena; idempotent, and safe to call on every boot + */ + public static function protect(Arena $arena): void + { + self::$protected[$arena->baseAddress()] = $arena; + } + + /** + * Whether an address belongs to an arena this process must not free from + */ + public static function isProtected(int $address): bool + { + foreach (self::$protected as $arena) { + if ($arena->contains($address)) { + return true; + } + } + + return false; + } + /** * Frees one persistent object with everything it exclusively owns * @@ -119,6 +168,8 @@ public static function reclaimEntry(PersistedEntry $entry): void */ private static function destroyTable(CData $table): void { + self::assertFreeable($table, 'shared table'); + PersistentHashTable::fromCData($table)->destroy(); } @@ -127,10 +178,25 @@ private static function destroyTable(CData $table): void */ private static function freeBlock(CData $pointer): void { + self::assertFreeable($pointer, 'shared block'); + // Blocks persisted by an earlier request are not in the registry anymore, blocks // from THIS request are - and a stale entry pointing at freed memory would let a // later untrackAndFree() free a recycled address a second time Core::untrack($pointer); Core::persistentFree($pointer); } + + /** + * Refuses a block that lives in a protected arena, naming the role of this process + */ + private static function assertFreeable(CData $pointer, string $what): void + { + $address = Core::addressOf($pointer); + foreach (self::$protected as $arena) { + if ($arena->contains($address)) { + throw ArenaException::blockNotFreeable($what, $address, $arena->isCreator()); + } + } + } } diff --git a/src/Registry.php b/src/Registry.php index b8290da..2183869 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -14,7 +14,11 @@ namespace Lisachenko\SharedData; use FFI\CData; +use Lisachenko\SharedData\Shm\ArenaAllocator; +use Lisachenko\SharedData\Shm\ArenaException; +use Lisachenko\SharedData\Shm\ArenaRegistryLayout; use ZEngine\Core; +use ZEngine\Generated\Bucket; use ZEngine\Reflection\ReflectionValue; use ZEngine\Type\PersistentHashTable; use ZEngine\Type\StringEntry; @@ -22,7 +26,7 @@ /** * Persistent registry of named object graphs, anchored in the module globals * - * Layout v3 (everything in persistent memory, valid across requests): + * Layout v4 (everything in persistent memory, valid across requests): * * root table: 'entries' => IS_PTR entry table: name (interned) => IS_PTR entry record * 'objects' => IS_PTR object table: clone address (int key) => IS_PTR object record @@ -37,8 +41,30 @@ * 'shares' => IS_LONG number of entries referencing this object * 'arrays' => IS_PTR index => IS_PTR sealed array HashTable* * (allocation list owned by this object) + * 'mutable' => IS_LONG 1 when the object belongs to a SHARED MUTABLE + * graph, 0 for the frozen default * - * The split between entries and objects is what v3 is about. Layout v2 stored the whole + * ## Where those tables live: process heap, or the fork-shared arena + * + * The shape above is the same in both modes; what differs is the ALLOCATOR behind it. + * + * - default (frozen) mode: every table is a malloc-backed PersistentHashTable that grows + * on demand, anchored by the address in module globals[0]. Per-worker memory, exactly + * as it has always been; + * - arena mode (Registry::createInArena): the struct AND the bucket storage of every + * table come out of the fork-shared arena, pre-sized once and NEVER grown - the engine + * would grow a table by reallocating its data block into the private heap of whichever + * worker happened to fill it, so the tables refuse the insert instead (z-engine's + * external-storage guard). Module globals[0] then holds the ARENA BASE rather than the + * registry address, and the registry tables are found through the arena's own roots + * directory, which is the only thing a forked child can rely on. + * + * Layout v4 is that second mode: the record shapes are unchanged from v3, but a worker + * cannot tell from a registry pointer alone whether it is looking at heap tables or at + * arena tables it must never free, so the version had to move. LAYOUT_VERSION is verified + * on every boot - see PersistentStore::boot(). + * + * The split between entries and objects is what v3 was about. Layout v2 stored the whole * graph inside its entry (parallel index-keyed tables of objects, snapshots, classes and * signatures), which made an object the exclusive property of one entry. Objects now live * in ONE process-wide table keyed by the clone's own address, so: @@ -62,15 +88,38 @@ final class Registry { /** * Version tag of the persistent layout described above, stored in module globals[1] + * + * Version history: + * v1 - one object per entry + * v2 - a whole graph per entry, in parallel index-keyed tables owned by that entry + * v3 - entries and objects split into two tables; objects shared between entries + * v4 - the same shape, but the tables may live in a fork-shared arena instead of the + * process heap (blocks the engine must never grow and this process must never + * free), and globals[0] then anchors the ARENA rather than the registry + * v5 - object records carry their ROLE ('mutable'): a shared graph that opted into + * mutation is never rolled back at request end, and a worker that cannot read the + * role would apply frozen semantics to memory its siblings are writing */ - public const LAYOUT_VERSION = 3; + public const LAYOUT_VERSION = 5; + + /** + * Sign correction for nTableMask, which the engine declares unsigned and uses signed + */ + private const int INT32_MAX = 0x7FFFFFFF; + private const int UINT32_RANGE = 0x100000000; private PersistentHashTable $entries; private PersistentHashTable $objects; - private function __construct(private PersistentHashTable $root) - { + /** + * @param ArenaAllocator|null $allocator Source of every table this registry mints; null + * is the malloc-backed default (frozen mode) + */ + private function __construct( + private PersistentHashTable $root, + private readonly ?ArenaAllocator $allocator = null, + ) { $this->entries = self::tableAt($root, 'entries'); $this->objects = self::tableAt($root, 'objects'); } @@ -80,14 +129,7 @@ private function __construct(private PersistentHashTable $root) */ public static function fromAddress(int $address): self { - // The cast below is a VIEW over this scalar's storage, so it must not be an - // FFI-owned allocation (the wrapper would dangle once the scalar is collected); - // request-lifetime memory is exactly right - the registry is re-recovered from - // module globals on every request anyway - $rawAddress = Core::new('uintptr_t', false); - $rawAddress->cdata = $address; - - return new self(PersistentHashTable::fromCData(Core::cast('HashTable *', $rawAddress))); + return new self(self::tableAtAddress($address)); } /** @@ -104,6 +146,64 @@ public static function create(): array return [new self($root), Core::addressOf($root->getRawValue())]; } + /** + * Creates a registry whose tables live in the fork-shared arena + * + * Called ONCE, by the process that owns the arena, before any worker is forked. The + * three tables are pre-sized from $layout and published in the arena's roots directory, + * which is how a child (or a later request of this same worker) finds them again with + * nothing but the arena mapping in hand. + * + * @return array{0: self, 1: int} Registry plus the ARENA BASE to store in module globals + */ + public static function createInArena(ArenaAllocator $allocator, ?ArenaRegistryLayout $layout = null): array + { + $layout ??= new ArenaRegistryLayout(); + $arena = $allocator->arena(); + + $root = $allocator->createTable(ArenaRegistryLayout::RECORD_CAPACITY); + $entries = $allocator->createTable($layout->entryCapacity); + $objects = $allocator->createTable($layout->objectCapacity); + + self::addPointer($root, 'entries', $entries->getRawValue(), $allocator); + self::addPointer($root, 'objects', $objects->getRawValue(), $allocator); + + $arena->putRoot(ArenaRegistryLayout::ROOT_TABLE, Core::addressOf($root->getRawValue())); + $arena->putRoot(ArenaRegistryLayout::ROOT_ENTRIES, Core::addressOf($entries->getRawValue())); + $arena->putRoot(ArenaRegistryLayout::ROOT_OBJECTS, Core::addressOf($objects->getRawValue())); + + return [new self($root, $allocator), $arena->baseAddress()]; + } + + /** + * Recovers an arena-resident registry through the arena's roots directory + * + * The only recovery path a forked worker has: it inherits the mapping, looks the root + * table up by name and rebuilds its view over tables it did not create. + */ + public static function fromArena(ArenaAllocator $allocator): self + { + $address = $allocator->arena()->requireRoot(ArenaRegistryLayout::ROOT_TABLE); + $registry = new self(self::tableAtAddress($address), $allocator); + + // Recovery is the moment to notice that a previous worker made the engine grow one + // of these tables: the resize writes the new private-heap block into the SHARED + // struct before it aborts, so a sibling would otherwise read plausible garbage out + // of memory that belongs to a process that is already gone + $registry->assertArenaResident($registry->entries, 'entries'); + $registry->assertArenaResident($registry->objects, 'objects'); + + return $registry; + } + + /** + * Whether this registry's tables live in the fork-shared arena + */ + public function isArenaBacked(): bool + { + return $this->allocator !== null; + } + /** * Registers a freshly persisted graph under $name, sharing what is already persisted * @@ -121,18 +221,20 @@ public function store(string $name, PersistedEntry $entry): void $this->adjustShares($address, +1); } - $members = new PersistentHashTable(); + $members = $this->newTable(\count($entry->members)); foreach ($entry->members as $index => $address) { - self::addLong($members, $index, $address); + self::addLong($members, $index, $address, $this->allocator); } - $meta = new PersistentHashTable(); - self::addLong($meta, 'count', $entry->count()); - self::addPointer($meta, 'members', $members->getRawValue()); + $meta = $this->newTable(ArenaRegistryLayout::RECORD_CAPACITY); + self::addLong($meta, 'count', $entry->count(), $this->allocator); + self::addPointer($meta, 'members', $members->getRawValue(), $this->allocator); // add() is an upsert: a previous record under this name is simply replaced, which // is why PersistentStore hydrates it BEFORE calling store() - self::addPointer($this->entries, $name, $meta->getRawValue()); + $this->assertRegistryRoom($this->entries, 'entries', $this->entries->find($name) !== null); + + self::addPointer($this->entries, $name, $meta->getRawValue(), $this->allocator); } /** @@ -145,7 +247,7 @@ public function removeEntry(string $name, PersistedEntry $entry): void { $this->entries->delete($name); - Reclaimer::reclaimEntry($entry); + $this->reclaimEntry($entry); } /** @@ -157,7 +259,7 @@ public function removeEntry(string $name, PersistedEntry $entry): void */ public function discardEntry(PersistedEntry $entry): void { - Reclaimer::reclaimEntry($entry); + $this->reclaimEntry($entry); } /** @@ -169,7 +271,23 @@ public function removeObject(PersistedObject $object): void { $this->objects->deleteIndex($object->address); - Reclaimer::reclaimObject($object); + // Arena blocks are never given back one by one: the region is reclaimed as a whole + // when its creating process exits, and a free() through this process's allocator + // would be a free() of memory the process heap never handed out (leak-until-teardown + // v1 - see Arena). Heap registries reclaim exactly as they did in v3. + if ($this->allocator === null) { + Reclaimer::reclaimObject($object); + } + } + + /** + * Reclaims an entry's bookkeeping, unless the tables belong to the arena + */ + private function reclaimEntry(PersistedEntry $entry): void + { + if ($this->allocator === null) { + Reclaimer::reclaimEntry($entry); + } } /** @@ -187,6 +305,9 @@ public function adjustShares(int $address, int $delta): int $shares = $object->shares + $delta; \assert($object->metaTable !== null); + // Deliberately WITHOUT the arena allocator: 'shares' is always an upsert of a key + // the record already carries, and the engine keeps the bucket's original key - a + // fresh arena string per share adjustment would be an unbounded leak for nothing self::addLong(PersistentHashTable::fromCData($object->metaTable), 'shares', $shares); return $shares; @@ -272,24 +393,30 @@ public function objectCount(): int */ private function addObject(PersistedObject $object): void { - $arrays = new PersistentHashTable(); + $arrays = $this->newTable(\count($object->arrays)); foreach ($object->arrays as $index => $array) { - self::addPointer($arrays, $index, $array); + self::addPointer($arrays, $index, $array, $this->allocator); } - $meta = new PersistentHashTable(); - self::addPointer($meta, 'object', $object->object); - self::addPointer($meta, 'snapshot', $object->snapshot); - self::addInternedString($meta, 'class', $object->className); - self::addInternedString($meta, 'signature', $object->signature); - self::addLong($meta, 'shares', 0); - self::addPointer($meta, 'arrays', $arrays->getRawValue()); + $meta = $this->newTable(ArenaRegistryLayout::RECORD_CAPACITY); + self::addPointer($meta, 'object', $object->object, $this->allocator); + self::addPointer($meta, 'snapshot', $object->snapshot, $this->allocator); + self::addInternedString($meta, 'class', $object->className, $this->allocator); + self::addInternedString($meta, 'signature', $object->signature, $this->allocator); + self::addLong($meta, 'shares', 0, $this->allocator); + self::addPointer($meta, 'arrays', $arrays->getRawValue(), $this->allocator); + // The role travels with the object, not with the process that persisted it: a sibling + // attaching this address later has to learn from the registry alone whether rolling + // the slots back at request end would destroy somebody's live writes + self::addLong($meta, 'mutable', $object->mutable ? 1 : 0, $this->allocator); $object->shares = 0; $object->metaTable = $meta->getRawValue(); $object->arraysTable = $arrays->getRawValue(); - self::addPointer($this->objects, $object->address, $meta->getRawValue()); + $this->assertRegistryRoom($this->objects, 'objects', $this->objects->findIndex($object->address) !== null); + + self::addPointer($this->objects, $object->address, $meta->getRawValue(), $this->allocator); } private function hydrateEntry(ReflectionValue $metaValue): PersistedEntry @@ -314,6 +441,7 @@ private static function hydrateObject(int $address, ReflectionValue $metaValue): $meta->find('class')->getNativeValue($className); $meta->find('signature')->getNativeValue($signature); $meta->find('shares')->getNativeValue($shares); + $meta->find('mutable')->getNativeValue($mutable); $arraysTable = self::tableAt($meta, 'arrays'); @@ -332,9 +460,107 @@ private static function hydrateObject(int $address, ReflectionValue $metaValue): $shares, $meta->getRawValue(), $arraysTable->getRawValue(), + $mutable === 1, ); } + /** + * Rebuilds a borrowed view over a persistent table living at a raw address + */ + private static function tableAtAddress(int $address): PersistentHashTable + { + // The cast below is a VIEW over this scalar's storage, so it must not be an + // FFI-owned allocation (the wrapper would dangle once the scalar is collected); + // request-lifetime memory is exactly right - the registry is re-recovered from + // module globals on every request anyway + $rawAddress = Core::new('uintptr_t', false); + $rawAddress->cdata = $address; + + return PersistentHashTable::fromCData(Core::cast('HashTable *', $rawAddress)); + } + + /** + * Refuses an insert that would make the engine grow an ARENA-resident registry table + * + * z-engine guards its own external-storage tables, but only while the wrapper that + * installed the storage is alive: a registry recovered from the arena (a later request, + * or a forked child) rebuilds BORROWED views over tables it did not create, and such a + * view knows nothing about the block behind it. So the guard is re-derived from the + * table itself - the engine resizes exactly when an insert finds every bucket slot used, + * which is `nNumUsed == nTableSize`, and an upsert of an existing key consumes no slot. + * + * A growth here would perealloc() arena memory into the private heap of whichever + * worker filled the table, silently unsharing the registry; the hard failure is the + * whole point. Heap registries (frozen mode) grow exactly as they always did. + * + * TODO: drop this in favour of a z-engine re-attachment API (a borrowed view that can + * adopt the external block it is sitting on) once lisachenko/z-engine#223 offers one. + */ + private function assertRegistryRoom(PersistentHashTable $table, string $label, bool $isUpsert): void + { + if ($this->allocator === null || $isUpsert) { + return; + } + $raw = $table->getRawValue(); + $used = (int) $raw->nNumUsed; + $tableSize = (int) $raw->nTableSize; + if ($used < $tableSize) { + return; + } + + throw ArenaException::registryTableFull($label, $tableSize); + } + + /** + * Verifies that a registry table's bucket storage still lives inside the arena + * + * The one observable symptom of an engine resize on shared storage: zend_hash grows a + * table by reallocating HT_GET_DATA_ADDR into the process heap and writes the new + * address into the shared struct - it does NOT crash there, it crashes (or does not) + * much later, so the pointer is the only honest evidence. The block address is + * recovered exactly like the engine's macro does it: + * + * HT_GET_DATA_ADDR(ht) = (char *) ht->arData - HT_HASH_SIZE(ht->nTableMask) + * HT_HASH_SIZE(mask) = -(int32_t) mask * sizeof(uint32_t) + * + * nTableMask is declared unsigned but always USED signed (it is -(2 * nTableSize) for + * an initialized table), which is why it is sign-corrected before the multiplication. + */ + private function assertArenaResident(PersistentHashTable $table, string $label): void + { + if ($this->allocator === null) { + return; + } + $raw = $table->getRawValue(); + $arData = $raw->arData; + if ($arData === null) { + return; // never initialized: no storage to misplace + } + + $mask = (int) $raw->nTableMask; + if ($mask > self::INT32_MAX) { + $mask -= self::UINT32_RANGE; + } + $hashSize = -$mask * 4; + $dataStart = Core::addressOf($arData) - $hashSize; + $dataSize = $hashSize + (int) $raw->nTableSize * Core::sizeOfType(Bucket::class); + + if (!$this->allocator->arena()->contains($dataStart, $dataSize)) { + throw ArenaException::registryTableRelocated($label, $dataStart); + } + } + + /** + * Mints one registry table, from the arena when this registry is arena-backed + * + * Arena tables are pre-sized for $capacity buckets and can never be grown by the + * engine; heap tables ignore the hint and grow on demand, exactly as in v3. + */ + private function newTable(int $capacity): PersistentHashTable + { + return $this->allocator?->createTable($capacity) ?? new PersistentHashTable(); + } + /** * Recovers a nested persistent table stored as an IS_PTR value under $key */ @@ -353,47 +579,68 @@ private static function tableAt(PersistentHashTable $table, string $key): Persis * (an 8-byte pointer CData cannot be cast to a 16-byte zval), direct union-member * assignment can. The engine copies the temporary container into its bucket. */ - private static function addPointer(PersistentHashTable $table, string|int $key, CData $pointer): void - { + private static function addPointer( + PersistentHashTable $table, + string|int $key, + CData $pointer, + ?ArenaAllocator $allocator = null, + ): void { $container = Core::new('zval'); $container->value->ptr = Core::cast('void *', $pointer); $container->u1->type_info = ReflectionValue::IS_PTR; - self::addValue($table, $key, $container); + self::addValue($table, $key, $container, $allocator); } - private static function addInternedString(PersistentHashTable $table, string|int $key, string $string): void - { - $interned = StringEntry::persistentInterned($string); + private static function addInternedString( + PersistentHashTable $table, + string|int $key, + string $string, + ?ArenaAllocator $allocator = null, + ): void { + $interned = StringEntry::persistentInterned($string, $allocator); $container = Core::new('zval'); $container->value->str = $interned->getRawValue(); // Bare IS_STRING: interned payloads are stored without refcounting $container->u1->type_info = ReflectionValue::IS_STRING; - self::addValue($table, $key, $container); + self::addValue($table, $key, $container, $allocator); } - private static function addLong(PersistentHashTable $table, string|int $key, int $number): void - { + private static function addLong( + PersistentHashTable $table, + string|int $key, + int $number, + ?ArenaAllocator $allocator = null, + ): void { $container = Core::new('zval'); $container->value->lval = $number; $container->u1->type_info = ReflectionValue::IS_LONG; - self::addValue($table, $key, $container); + self::addValue($table, $key, $container, $allocator); } /** * Stores a hand-built zval container under a string or integer key */ - private static function addValue(PersistentHashTable $table, string|int $key, CData $container): void - { + private static function addValue( + PersistentHashTable $table, + string|int $key, + CData $container, + ?ArenaAllocator $allocator = null, + ): void { $value = ReflectionValue::fromValueEntry(Core::addr($container)); if (\is_int($key)) { $table->addIndex($key, $value); - } else { + } elseif ($allocator === null) { $table->add($key, $value); + } else { + // The KEY has to live in the arena as well: add() would mint a malloc-backed + // interned string, and a sibling process walking this table would follow that + // pointer into memory it never allocated + $table->addInterned(StringEntry::persistentInterned($key, $allocator), $value); } } } diff --git a/src/SharedMutationException.php b/src/SharedMutationException.php new file mode 100644 index 0000000..cb836cb --- /dev/null +++ b/src/SharedMutationException.php @@ -0,0 +1,156 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData; + +/** + * Every way a shared-mutable write can be refused, with the remedy in the message + * + * The mutation contract of a shared graph is narrow on purpose: a slot may hold a scalar, an + * arena-interned string, or a pointer to another object of the same arena, and nothing else. + * Anything wider would put a request-heap pointer into memory other processes read, which is + * not an exception in the moment - it is a segfault in a sibling, later. So every refusal + * below happens BEFORE any lock is taken and before any byte is written. + */ +final class SharedMutationException extends \RuntimeException +{ + /** + * Mutable graphs need the fork-shared arena: there is nothing to synchronize without it + */ + public static function requiresSharedMode(string $className): self + { + return new self(sprintf( + 'Cannot persist %s as mutable: mutable graphs exist only in a fork-shared arena. Boot the ' . + 'store with PersistentStore::bootShared($arena) - in the default (frozen) mode a persisted ' . + 'graph is rolled back to its snapshot at request end, so there is nothing to share.', + $className, + )); + } + + /** + * The object is not (or no longer) part of this store's shared registry + */ + public static function notShared(string $className): self + { + return new self(sprintf( + 'The given %s instance is not a shared object of this store: only the instance returned by ' . + 'persist() (or attached through attachObject()) lives in the arena, and only such an ' . + 'instance has an address other processes can follow.', + $className, + )); + } + + /** + * A frozen graph refuses writes: its slots are restored from the snapshot at request end + */ + public static function notMutable(string $className, int $address): self + { + return new self(sprintf( + 'The shared %s at 0x%x belongs to a FROZEN graph and cannot be written: its properties are ' . + 'restored from the persisted snapshot when the request ends. Persist the graph with ' . + 'persist($key, $object, mutable: true) to opt into shared mutation.', + $className, + $address, + )); + } + + /** + * The class carries no such declared property slot (dynamic properties never exist here) + */ + public static function unknownProperty(string $className, string $property): self + { + return new self(sprintf( + 'Class %s declares no property $%s with a storage slot; shared objects have no dynamic ' . + 'properties, and static or hooked (virtual) properties own no slot to write.', + $className, + $property, + )); + } + + /** + * Array payloads stay sealed: a shared zend_array cannot grow, and growing it corrupts + */ + public static function sealedArrayProperty(string $className, string $property): self + { + return new self(sprintf( + 'Property %s::$%s holds a sealed shared array and cannot be written. A zend_array in the ' . + 'arena cannot be grown - the engine would move its bucket block into one worker\'s private ' . + 'heap and write that pointer into the shared struct before aborting. Use ' . + 'Lisachenko\\SharedData\\Ipc\\SharedArray for a mutable shared collection.', + $className, + $property, + )); + } + + /** + * A reference slot may only point at another object of the same arena + */ + public static function targetNotShared(string $className, string $property): self + { + return new self(sprintf( + 'Property %s::$%s can only reference an object that is itself persisted in this arena: a ' . + 'request-heap object address means nothing in a sibling process. Persist the target first ' . + '(persist($key, $object, mutable: true)) and write the instance persist() returned.', + $className, + $property, + )); + } + + /** + * The value does not satisfy the property's declared type + */ + public static function typeMismatch(string $className, string $property, string $declared, string $given): self + { + return new self(sprintf( + 'Property %s::$%s is declared %s and cannot hold a %s. The write API stores the value ' . + 'directly into the object slot, so the engine never gets the chance to coerce or to refuse ' . + 'it - the declared type is enforced here instead.', + $className, + $property, + $declared, + $given, + )); + } + + /** + * A read asked for a shape the slot does not currently hold + */ + public static function unexpectedSlotType(string $className, string $property, string $wanted, int $type): self + { + return new self(sprintf( + 'Property %s::$%s currently holds a value of zval type %d, which is not a %s; read it with ' . + 'the matching accessor, or with read() when the shape is not known up front.', + $className, + $property, + $type, + $wanted, + )); + } + + /** + * One object cannot belong to a frozen and to a mutable graph at the same time + */ + public static function modeConflict(string $className, string $memberClass, bool $wantsMutable): self + { + return new self(sprintf( + 'Cannot persist %s as %s: it reaches the already persisted %s, which belongs to a %s graph. ' . + 'One object cannot be both - a frozen graph rolls its members back at request end, which ' . + 'would silently undo what another process wrote through the mutable one. Drop the existing ' . + 'entry first, or persist both graphs in the same mode.', + $className, + $wantsMutable ? 'mutable' : 'frozen', + $memberClass, + $wantsMutable ? 'frozen' : 'mutable', + )); + } +} diff --git a/src/SharedObjectHandle.php b/src/SharedObjectHandle.php new file mode 100644 index 0000000..b25b448 --- /dev/null +++ b/src/SharedObjectHandle.php @@ -0,0 +1,621 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData; + +use FFI\CData; +use Lisachenko\SharedData\Shm\Arena; +use Lisachenko\SharedData\Shm\ArenaAllocator; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; +use ZEngine\Type\StringEntry; + +/** + * The SYNCHRONIZED way to read and write the properties of a shared mutable object + * + * A shared object is an ordinary PHP instance, so `$object->counter = 1` compiles, runs and + * even works: the engine writes the value straight into the arena and every sibling sees it + * immediately (docs/shared-memory-model.md, §2). What it is not is *synchronized*, and for + * anything but a scalar it is not even safe: + * + * - a plain write is two stores (payload word, then type word), and a reader without the + * lock was measured to observe the two halves from different writes in ~1.3 % of reads; + * - `$object->name = 'x'` stores a REQUEST-HEAP `zend_string` pointer inside shared memory. + * The writing process reads it back perfectly; a sibling following it dereferences memory + * that belongs to another process. The same is true of arrays and of objects that are not + * themselves arena-resident. + * + * This handle is the path that does it correctly: every write interns or validates its + * payload BEFORE taking the lock, and the critical section is nothing but aligned word + * stores, payload first and type word second. Reads take the same stripe lock, because the + * *type* of a slot can change under them - that is exactly the case correction #1 says needs + * the lock. The lock is `Arena::stripeFor($address)`, so unrelated objects rarely contend and + * two that collide merely serialize. + * + * Deliberately NOT done here: engine `write_property` handlers. A persistent clone is rewired + * to `std_object_handlers` by construction (that is the only handlers block whose address + * survives a request, let alone a fork), so there is no hook to install without giving that + * up. Direct property writes therefore stay legal and unsynchronized, and this API is the + * synchronized alternative rather than an enforcement layer. + * + * ## What may be written + * + * | Slot | Rule | + * |---|---| + * | scalar (`null`/`bool`/`int`/`float`) | overwritten in place, payload word then type word | + * | string | new bytes are interned in the ARENA, then the 8-byte pointer is swapped; the previous block leaks until the arena dies, because a reader may still be following it | + * | object reference | pointer swap to another object of THIS arena only | + * | array | refused - a shared `zend_array` cannot grow, so it stays sealed immutable (`Ipc\SharedArray` is the mutable collection) | + * + * Declared property types are enforced here, because the write goes straight into the slot + * and the engine never gets to check them. + */ +final class SharedObjectHandle +{ + /** + * Property name => slot index in properties_table, from the class entry of THIS process + * + * @var array + */ + private array $slots; + + /** + * Cached word views per property slot, so a critical section never creates a CData + * + * @var array + */ + private array $views = []; + + private readonly Arena $arena; + + private readonly int $stripe; + + /** + * zval* at properties_table[0] - the base every slot view is derived from + */ + private readonly CData $tableBase; + + private bool $recoveredLock = false; + + /** + * @param PersistentStore $store Owner of the registry this object belongs to + * @param ArenaAllocator $allocator Where new string payloads are interned + * @param CData $object zend_object* of the shared clone + * @param CData $classEntry zend_class_entry* bound by THIS process (never the + * advisory pointer inside the shared struct) + * @param int $address Arena address of the clone - its cross-process identity + * @param string $className For error messages and property reflection + * + * @internal Created by PersistentStore::mutableHandle() + */ + public function __construct( + private readonly PersistentStore $store, + private readonly ArenaAllocator $allocator, + private readonly CData $object, + private readonly CData $classEntry, + private readonly int $address, + private readonly string $className, + ) { + $this->arena = $allocator->arena(); + $this->stripe = $this->arena->stripeFor($address); + $this->tableBase = Core::cast('zval *', Core::addr($object->properties_table[0])); + $this->slots = array_flip(Persister::propertySlots($classEntry)); + } + + /** + * Arena address of this object: the identity to hand to another process + */ + public function address(): int + { + return $this->address; + } + + /** + * The stripe mutex serializing every access to this object + */ + public function stripe(): int + { + return $this->stripe; + } + + /** + * The PHP instance, for ordinary (unsynchronized) reads + */ + public function instance(): object + { + return $this->store->attachObject($this->address); + } + + /** + * Whether any lock taken by this handle was recovered from a worker that died holding it + * + * A recovered lock means the previous owner was killed between the two stores of a write, + * so a reader may have seen a payload word that does not match its type word. Every write + * through this handle repairs that by construction (it rewrites both words), which is why + * recovery is reported rather than thrown - the caller decides whether the value it just + * read has to be treated as suspect. + */ + public function wasLockRecovered(): bool + { + return $this->recoveredLock; + } + + /** + * Reads a scalar property under the stripe lock + */ + public function readScalar(string $property): int|float|bool|null + { + [$type, $lval, $dval] = $this->readSlot($property); + + return match ($type) { + ReflectionValue::IS_UNDEF, ReflectionValue::IS_NULL => null, + ReflectionValue::IS_TRUE => true, + ReflectionValue::IS_FALSE => false, + ReflectionValue::IS_LONG => $lval, + ReflectionValue::IS_DOUBLE => $dval, + default => throw SharedMutationException::unexpectedSlotType( + $this->className, + $property, + 'scalar', + $type, + ), + }; + } + + /** + * Reads a string property under the stripe lock + * + * The bytes themselves are never copied while the lock is held: the critical section + * takes the pointer, and the string is materialized afterwards. That is safe precisely + * because arena memory is never reclaimed per block - the previous payload of a slot + * stays readable even after another process has swapped it away. + */ + public function readString(string $property): ?string + { + [$type, $lval] = $this->readSlot($property); + + if ($type === ReflectionValue::IS_NULL || $type === ReflectionValue::IS_UNDEF) { + return null; + } + if ($type !== ReflectionValue::IS_STRING) { + throw SharedMutationException::unexpectedSlotType($this->className, $property, 'string', $type); + } + + return StringEntry::fromCData(Core::pointerAtAddress('zend_string *', $lval))->getStringValue(); + } + + /** + * Reads an object-reference property and attaches the target for this request + */ + public function readReference(string $property): ?object + { + [$type, $lval] = $this->readSlot($property); + + if ($type === ReflectionValue::IS_NULL || $type === ReflectionValue::IS_UNDEF) { + return null; + } + if ($type !== ReflectionValue::IS_OBJECT) { + throw SharedMutationException::unexpectedSlotType($this->className, $property, 'object', $type); + } + + // Attaching runs engine code and may register an object: never under a lock + return $this->store->attachObject($lval); + } + + /** + * Reads whatever the slot currently holds, dispatching on its type + * + * Array slots are the one shape read WITHOUT the lock, and legitimately so: a sealed + * array can neither be grown nor replaced through this API, so the slot is immutable and + * a lock would guard a value that cannot change. + */ + public function read(string $property): mixed + { + [$type, $lval, $dval] = $this->readSlot($property); + + return match ($type) { + ReflectionValue::IS_UNDEF, ReflectionValue::IS_NULL => null, + ReflectionValue::IS_TRUE => true, + ReflectionValue::IS_FALSE => false, + ReflectionValue::IS_LONG => $lval, + ReflectionValue::IS_DOUBLE => $dval, + ReflectionValue::IS_STRING => StringEntry::fromCData( + Core::pointerAtAddress('zend_string *', $lval), + )->getStringValue(), + ReflectionValue::IS_OBJECT => $this->store->attachObject($lval), + ReflectionValue::IS_ARRAY => $this->readArray($property), + default => throw SharedMutationException::unexpectedSlotType( + $this->className, + $property, + 'readable value', + $type, + ), + }; + } + + /** + * Overwrites a scalar property in place, payload word first and type word second + */ + public function writeScalar(string $property, int|float|bool|null $value): void + { + $value = $this->assertAssignable($property, $value); + + [$typeInfo, $payload, $isDouble] = match (true) { + $value === null => [ReflectionValue::IS_NULL, 0, false], + $value === true => [ReflectionValue::IS_TRUE, 0, false], + $value === false => [ReflectionValue::IS_FALSE, 0, false], + \is_int($value) => [ReflectionValue::IS_LONG, $value, false], + default => [ReflectionValue::IS_DOUBLE, $value, true], + }; + + $this->storeSlot($property, $typeInfo, $payload, $isDouble); + } + + /** + * Writes several scalar properties in ONE critical section + * + * The multi-slot half of the contract: a reader that takes the same lock either sees all + * of these values or none of them. Writing them one by one would publish a half-applied + * update between the calls - which is exactly what the sweep observed at the PHP level in + * 2.7-3.8 % of unlocked reads of a three-property update. + * + * @param array $values Property name => value + */ + public function writeScalars(array $values): void + { + $writes = []; + foreach ($values as $property => $value) { + $value = $this->assertAssignable($property, $value); + $view = $this->viewOf($property); + + $writes[] = match (true) { + $value === null => [$view, ReflectionValue::IS_NULL, 0, false], + $value === true => [$view, ReflectionValue::IS_TRUE, 0, false], + $value === false => [$view, ReflectionValue::IS_FALSE, 0, false], + \is_int($value) => [$view, ReflectionValue::IS_LONG, $value, false], + default => [$view, ReflectionValue::IS_DOUBLE, $value, true], + }; + } + + $recovered = $this->arena->lockStripe($this->stripe); + + foreach ($writes as [$view, $typeInfo, $payload, $isDouble]) { + if ($isDouble) { + $view['doubles'][0] = $payload; + } else { + $view['words'][0] = $payload; + } + $view['types'][2] = $typeInfo; + } + + $this->arena->unlockStripe($this->stripe); + + $this->recoveredLock = $this->recoveredLock || $recovered; + } + + /** + * Reads several scalar properties in ONE critical section + * + * The counterpart of writeScalars(): the values come from a single generation of the + * object, which is the only way a caller can compare two slots and conclude anything. + * + * @param list $properties + * + * @return array + */ + public function readScalars(array $properties): array + { + $views = []; + foreach ($properties as $property) { + $views[$property] = $this->viewOf($property); + } + + $raw = []; + + $recovered = $this->arena->lockStripe($this->stripe); + + foreach ($views as $property => $view) { + $raw[$property] = [(int) $view['types'][2] & 0xFF, (int) $view['words'][0], (float) $view['doubles'][0]]; + } + + $this->arena->unlockStripe($this->stripe); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + $values = []; + foreach ($raw as $property => [$type, $lval, $dval]) { + $values[$property] = match ($type) { + ReflectionValue::IS_UNDEF, ReflectionValue::IS_NULL => null, + ReflectionValue::IS_TRUE => true, + ReflectionValue::IS_FALSE => false, + ReflectionValue::IS_LONG => $lval, + ReflectionValue::IS_DOUBLE => $dval, + default => throw SharedMutationException::unexpectedSlotType( + $this->className, + $property, + 'scalar', + $type, + ), + }; + } + + return $values; + } + + /** + * Interns new bytes in the arena and swaps the 8-byte string pointer under the lock + * + * The old block is NOT freed: the arena is bump-allocated and a sibling may be holding + * the previous pointer at this very moment (an aligned 8-byte read never tears, so it is + * following a complete, valid string - just the older one). Rewriting a string property N + * times therefore costs N blocks until the arena dies; see docs/shared-memory-model.md §6. + */ + public function writeString(string $property, ?string $value): void + { + $this->assertAssignable($property, $value); + + if ($value === null) { + $this->writeScalar($property, null); + + return; + } + + // Interning allocates arena memory and calls into the engine: strictly before the lock + $interned = StringEntry::persistentInterned($value, $this->allocator); + $pointer = Core::addressOf($interned->getRawValue()); + + // Bare IS_STRING: an interned, immutable payload is held without refcounting, which + // is what lets every process copy the value around without touching a shared header + $this->storeSlot($property, ReflectionValue::IS_STRING, $pointer, false); + } + + /** + * Points an object property at another object of THIS arena + * + * The refcounted IS_OBJECT_EX type stays: request code copies such a value around + * normally, and the target's refcount pin absorbs every addref and delref it will see. + * Overwriting a slot that pointed at another shared object simply drops one unit of that + * object's pin, which is why no destructor call is needed - and must not be attempted + * under a lock anyway. + */ + public function writeReference(string $property, ?object $target): void + { + $this->assertAssignable($property, $target); + + if ($target === null) { + $this->writeScalar($property, null); + + return; + } + + $targetAddress = $this->store->addressOfInstance($target); + if ($targetAddress === null) { + throw SharedMutationException::targetNotShared($this->className, $property); + } + + $refcounted = 1 << Core::engineConstant('Z_TYPE_FLAGS_SHIFT'); + + $this->storeSlot($property, ReflectionValue::IS_OBJECT | $refcounted, $targetAddress, false); + } + + /** + * The one write path: payload word, then type word, under the object's stripe lock + * + * The slot's current type is examined INSIDE the same critical section, so a sealed array + * cannot slip in between a check and a write - and the refusal is thrown only after the + * lock is released, because throwing from a critical section would leave the stripe held + * (and, in an FFI callback, would not even be catchable). + */ + private function storeSlot(string $property, int $typeInfo, int|float $payload, bool $isDouble): void + { + $view = $this->viewOf($property); + + $recovered = $this->arena->lockStripe($this->stripe); + + // Payload BEFORE type, so a reader that legitimately skips the lock (a single aligned + // 8-byte pointer read of a slot whose type is fixed) can never see a new pointer under + // an old type or the other way round + $sealed = ((int) $view['types'][2] & 0xFF) === ReflectionValue::IS_ARRAY; + if (!$sealed) { + if ($isDouble) { + $view['doubles'][0] = $payload; + } else { + $view['words'][0] = $payload; + } + $view['types'][2] = $typeInfo; + } + + $this->arena->unlockStripe($this->stripe); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + if ($sealed) { + throw SharedMutationException::sealedArrayProperty($this->className, $property); + } + } + + /** + * Reads the three words of a slot under one lock + * + * All three come from the same critical section on purpose: the payload is interpreted + * differently depending on the type word, so reading them apart is exactly the torn read + * the sweep measured. Materializing the value (a string, an attached object) happens + * after the lock is released - it allocates, and allocation under an arena lock is + * forbidden. + * + * @return array{0: int, 1: int, 2: float} zval type, payload as an integer, payload as a double + */ + private function readSlot(string $property): array + { + $view = $this->viewOf($property); + + $recovered = $this->arena->lockStripe($this->stripe); + + $typeInfo = (int) $view['types'][2]; + $lval = (int) $view['words'][0]; + $dval = (float) $view['doubles'][0]; + + $this->arena->unlockStripe($this->stripe); + + $this->recoveredLock = $this->recoveredLock || $recovered; + + return [$typeInfo & 0xFF, $lval, $dval]; + } + + /** + * Binds (once per process and property) the word views used inside critical sections + * + * @return array{words: CData, types: CData, doubles: CData} + */ + private function viewOf(string $property): array + { + if (isset($this->views[$property])) { + return $this->views[$property]; + } + if (!isset($this->slots[$property])) { + throw SharedMutationException::unknownProperty($this->className, $property); + } + $slot = Core::addr($this->tableBase[$this->slots[$property]]); + + return $this->views[$property] = [ + // value word at offset 0, type_info at offset 8 (word index 2 of a uint32 view). + // u2 is deliberately never touched: for an uninitialized typed property it carries + // the engine's property flags + 'words' => Core::cast('uint64_t *', $slot), + 'types' => Core::cast('uint32_t *', $slot), + 'doubles' => Core::cast('double *', $slot), + ]; + } + + /** + * Reads a sealed array slot through ordinary engine access + * + * Immutable payloads are copied on write by the engine, so what the caller receives is a + * request-local array that shares its buckets with the arena until it is modified. + */ + private function readArray(string $property): array + { + $instance = $this->instance(); + + try { + $value = new \ReflectionProperty($this->className, $property)->getValue($instance); + } catch (\ReflectionException) { + $value = $instance->{$property}; + } + \assert(\is_array($value)); + + return $value; + } + + /** + * Enforces the property's declared type, which the engine never gets to check + * + * @return int|float|bool|null The value to store, widened to float where the declaration + * asks for it (the one coercion the engine would have done) + */ + private function assertAssignable(string $property, mixed $value): mixed + { + $declared = $this->declaredTypeOf($property); + if ($declared === null) { + return $value; + } + $names = $declared['names']; + if (\in_array('mixed', $names, true)) { + return $value; + } + + if ($value === null) { + if (!$declared['nullable'] && !\in_array('null', $names, true)) { + throw SharedMutationException::typeMismatch( + $this->className, + $property, + implode('|', $names), + 'null', + ); + } + + return null; + } + + if (\is_object($value)) { + foreach ($names as $name) { + if ($value instanceof $name) { + return $value; + } + } + + throw SharedMutationException::typeMismatch( + $this->className, + $property, + implode('|', $names), + \get_class($value), + ); + } + + $given = \get_debug_type($value); + if (\in_array($given, $names, true)) { + return $value; + } + // int -> float is the only widening a typed property performs silently + if ($given === 'int' && \in_array('float', $names, true)) { + return (float) $value; + } + + throw SharedMutationException::typeMismatch($this->className, $property, implode('|', $names), $given); + } + + /** + * The declared type of one property, or null when it is untyped (or not reflectable) + * + * A property declared private by a PARENT class owns a slot in this object but is not + * reachable through ReflectionProperty on the child; such a slot is written without a + * type check, exactly as an untyped property is. + * + * @return array{names: list, nullable: bool}|null + */ + private function declaredTypeOf(string $property): ?array + { + try { + $type = new \ReflectionProperty($this->className, $property)->getType(); + } catch (\ReflectionException) { + return null; + } + if ($type === null) { + return null; + } + + $names = []; + foreach ($type instanceof \ReflectionNamedType ? [$type] : $this->typeParts($type) as $part) { + $names[] = $part->getName(); + } + + return ['names' => $names, 'nullable' => $type->allowsNull()]; + } + + /** + * @return list<\ReflectionNamedType> + */ + private function typeParts(\ReflectionType $type): array + { + $parts = []; + if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { + foreach ($type->getTypes() as $part) { + if ($part instanceof \ReflectionNamedType) { + $parts[] = $part; + } + } + } + + return $parts; + } +} diff --git a/src/Shm/Arena.php b/src/Shm/Arena.php new file mode 100644 index 0000000..1928712 --- /dev/null +++ b/src/Shm/Arena.php @@ -0,0 +1,885 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use FFI; +use FFI\CData; + +/** + * One fixed-size region of fork-shared memory with a bump allocator in front of it + * + * The arena is the foundation of cross-process object sharing: a single + * `mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0)` region created + * BEFORE any worker is forked. Every child inherits the mapping at the very same virtual + * address, so an address handed to a sibling over a pipe as eight raw bytes means the same + * thing there - which is what lets whole PHP values be exchanged without serialization. + * + * ## Why it must be created pre-fork + * + * `MAP_SHARED|MAP_ANONYMOUS` memory is shared with the CHILDREN of the process that + * created it (and with its own later self), never with unrelated processes. Two workers + * see the same bytes because they descend from one parent that mapped them; a process that + * maps its own arena after the fork gets private memory that happens to look identical. + * + * ## Layout + * + * ```text + * 0 header words magic, layout version, size, cursor, creator pid, + * mutex slot size, roots capacity + * 128 mutex bank 64 slots x 64 bytes, PTHREAD_PROCESS_SHARED + ROBUST + * slot 0 = allocator, slot 1 = roots directory, 2.. = stripes + * 4224 roots dir 64 entries x 64 bytes: hash, address, name length, name[40] + * 16384 payload bump-allocated, cursor grows upwards, never shrinks + * ``` + * + * The mutex bank reserves 64 bytes per `pthread_mutex_t` (40 on x86-64/arm64 glibc, + * measured at runtime by Libc::probeMutexSize() rather than assumed) so a platform with a + * bigger mutex is a clean exception instead of neighbouring slots overlapping. + * + * ## Allocation model: bump, and leak until teardown + * + * `allocate()` moves one cursor forward under the allocator mutex and never moves it back. + * There is no free list and no per-block header: blocks are released when the arena dies, + * which is when the CREATING process EXITS - the kernel reclaims the mapping, and nothing + * unmaps it earlier (see destroy(): request shutdown releases the last references to shared + * objects after its shutdown functions have run, so an unmap armed there is a segfault + * waiting for the right order). This is deliberate for v1: + * a shared free list needs cross-process reachability accounting that nothing here can + * provide yet. Exhaustion is therefore a normal, typed outcome - ArenaException::exhausted(). + * + * ## Locking rules + * + * While ANY arena mutex is held, only word loads and stores through the cached views + * below are allowed: no engine call that can allocate, no userland callback, nothing that + * can throw. Every method here obeys that - the critical sections are single aligned + * 64-bit updates, which is also why a lock recovered from a died owner (`EOWNERDEAD`) can + * simply be declared consistent again: a torn state is not reachable. + * + * ## Public surface + * + * No method returns `FFI\CData`. Callers see integers (addresses, sizes) and strings; the + * pointer views are private, created once per process at map time, and reused - creating + * them per call would both leak FFI type structures and violate the locking rule above. + */ +final class Arena +{ + /** + * "SHMARENA" in ASCII, the first word of every arena + */ + public const int MAGIC = 0x53484D4152454E41; + + /** + * Version of the layout described above; bumped whenever any offset here moves + */ + public const int LAYOUT_VERSION = 1; + + /** + * Header size, and therefore the offset of the first allocatable byte + */ + public const int HEADER_SIZE = 16384; + + public const int MUTEX_OFFSET = 128; + public const int MUTEX_COUNT = 64; + public const int MUTEX_SLOT_SIZE = 64; + public const int MUTEX_SIZE_FLOOR = 40; + + /** + * Alignment of a DEDICATED mutex allocated in the payload by allocateMutex() + * + * One cache line, exactly like the bank's stride: two mutexes sharing a line would make + * unrelated structures fight over the same cache line on every lock. + */ + public const int MUTEX_ALIGNMENT = 64; + + /** + * Mutex 0 serializes the bump cursor, mutex 1 the roots directory; the rest are free + * for consumers (stripe locks over data structures living in the arena) + */ + public const int ALLOCATOR_MUTEX = 0; + public const int ROOTS_MUTEX = 1; + public const int FIRST_STRIPE = 2; + + public const int ROOTS_OFFSET = 4224; + public const int ROOT_CAPACITY = 64; + public const int ROOT_ENTRY_SIZE = 64; + public const int ROOT_ENTRY_WORDS = 8; + public const int ROOT_NAME_SIZE = 40; + + /** + * Default arena size, overridable through the SHARED_DATA_ARENA_SIZE environment + * variable (plain bytes, or a K/M/G suffix) + */ + public const int DEFAULT_SIZE = 64 * 1024 * 1024; + + public const string SIZE_ENV = 'SHARED_DATA_ARENA_SIZE'; + + /** + * Word indexes into the header + */ + private const int WORD_MAGIC = 0; + private const int WORD_LAYOUT_VERSION = 1; + private const int WORD_SIZE = 2; + private const int WORD_CURSOR = 3; + private const int WORD_CREATOR_PID = 4; + private const int WORD_MUTEX_SIZE = 5; + private const int WORD_ROOT_CAPACITY = 6; + + private const int MAX_ALIGNMENT = 4096; + + /** + * Offsets of the fields inside one roots-directory entry, in words + */ + private const int ROOT_WORD_HASH = 0; + private const int ROOT_WORD_ADDRESS = 1; + private const int ROOT_WORD_LENGTH = 2; + private const int ROOT_WORD_NAME = 3; + + /** + * char* over the whole mapping - the anchor every other view is derived from + */ + private CData $base; + + /** + * uint64_t* over the whole mapping: header fields and roots entries are word indexes + * into this one view, so a critical section never has to create a CData + */ + private CData $words; + + /** + * char* at each mutex slot, materialized on first use and then reused forever + * + * @var array + */ + private array $mutexes = []; + + /** + * char* at each DEDICATED payload mutex, keyed by its arena address (see allocateMutex()) + * + * @var array + */ + private array $ownedMutexes = []; + + private bool $released = false; + + private function __construct( + private readonly int $baseAddress, + private readonly int $size, + private readonly int $creatorPid, + ) { + } + + /** + * Maps a new arena; call this ONCE, before any worker is forked + * + * @param int|null $size Total size in bytes, header included; defaults to the + * SHARED_DATA_ARENA_SIZE environment variable or 64 MB + */ + public static function create(?int $size = null): self + { + $size ??= self::configuredSize(); + if ($size <= self::HEADER_SIZE || $size % 4096 !== 0) { + throw ArenaException::invalidSize($size); + } + + $mutexSize = Libc::probeMutexSize(); + if ($mutexSize > self::MUTEX_SLOT_SIZE) { + throw ArenaException::mutexSlotTooSmall($mutexSize, self::MUTEX_SLOT_SIZE); + } + + $mapping = Libc::mapShared($size); + $arena = new self(Libc::addressOf($mapping), $size, getmypid()); + $arena->bindViews($mapping); + + // The mapping is zero-filled by the kernel, so every roots entry already reads as + // empty and the cursor only has to be lifted over the header + $arena->words[self::WORD_MAGIC] = self::MAGIC; + $arena->words[self::WORD_LAYOUT_VERSION] = self::LAYOUT_VERSION; + $arena->words[self::WORD_SIZE] = $size; + $arena->words[self::WORD_CURSOR] = self::HEADER_SIZE; + $arena->words[self::WORD_CREATOR_PID] = $arena->creatorPid; + $arena->words[self::WORD_MUTEX_SIZE] = $mutexSize; + $arena->words[self::WORD_ROOT_CAPACITY] = self::ROOT_CAPACITY; + + for ($index = 0; $index < self::MUTEX_COUNT; $index++) { + Libc::initSharedMutex($arena->mutexAt($index)); + } + + // Deliberately NO shutdown function that unmaps: see destroy() for why unmapping at + // request shutdown is unsafe by ordering, and why the process exit is the right moment + return $arena; + } + + /** + * Reads the configured arena size (bytes, or a K/M/G suffix) from the environment + */ + public static function configuredSize(): int + { + $configured = getenv(self::SIZE_ENV); + if ($configured === false || trim($configured) === '') { + return self::DEFAULT_SIZE; + } + $configured = strtoupper(trim($configured)); + if (preg_match('/^(\d+)([KMG]?)B?$/', $configured, $matches) !== 1) { + throw ArenaException::invalidSize(0); + } + + $multiplier = match ($matches[2]) { + 'K' => 1024, + 'M' => 1024 * 1024, + 'G' => 1024 * 1024 * 1024, + default => 1, + }; + + return (int) $matches[1] * $multiplier; + } + + /** + * Address of the first byte of the mapping (identical in every forked child) + */ + public function baseAddress(): int + { + return $this->baseAddress; + } + + /** + * Total size of the mapping, header included + */ + public function size(): int + { + return $this->size; + } + + /** + * Bytes available to allocate() when the arena is empty + */ + public function capacity(): int + { + return $this->size - self::HEADER_SIZE; + } + + /** + * High-water mark: payload bytes handed out so far, alignment padding included + * + * The number never falls (blocks are never returned), which makes it the honest gauge + * for "does this workload plateau?" in soak runs. + */ + public function watermark(): int + { + $this->assertLive(); + + return $this->cursor() - self::HEADER_SIZE; + } + + /** + * Bytes still allocatable + */ + public function remaining(): int + { + $this->assertLive(); + + return $this->size - $this->cursor(); + } + + /** + * Whether this process is the one that created the arena (and will unmap it) + */ + public function isCreator(): bool + { + return getmypid() === $this->creatorPid; + } + + /** + * Process id of the creator - the only process allowed to unmap + */ + public function creatorPid(): int + { + return $this->creatorPid; + } + + /** + * Measured size of this platform's pthread_mutex_t, as recorded in the header + */ + public function mutexSize(): int + { + $this->assertLive(); + + return (int) $this->words[self::WORD_MUTEX_SIZE]; + } + + /** + * Number of mutex slots consumers may use as stripe locks + */ + public function stripeCount(): int + { + return self::MUTEX_COUNT - self::FIRST_STRIPE; + } + + /** + * Hands out $size bytes of arena memory, aligned to $align, and returns their address + * + * Safe to call from any process that inherited the arena: the cursor lives in shared + * memory and is moved under the shared allocator mutex, so two children allocating at + * the same moment get disjoint blocks. + * + * @param int $size Bytes to reserve (must be positive) + * @param int $align Power-of-two alignment of the returned address, at most 4096 + * + * @return int Absolute address of the block, valid in every process of the family + */ + public function allocate(int $size, int $align = 16): int + { + $this->assertLive(); + if ($size <= 0) { + throw ArenaException::invalidSize($size); + } + if ($align <= 0 || $align > self::MAX_ALIGNMENT || ($align & ($align - 1)) !== 0) { + throw ArenaException::invalidAlignment($align); + } + + // Everything that can throw or allocate is done BEFORE the lock is taken; the + // critical section below is two word accesses and nothing else + $mutex = $this->mutexAt(self::ALLOCATOR_MUTEX); + + Libc::lockMutex($mutex, self::ALLOCATOR_MUTEX); + + $cursor = (int) $this->words[self::WORD_CURSOR]; + $aligned = ($cursor + $align - 1) & ~($align - 1); + $next = $aligned + $size; + $fits = $next <= $this->size; + if ($fits) { + $this->words[self::WORD_CURSOR] = $next; + } + + Libc::unlockMutex($mutex, self::ALLOCATOR_MUTEX); + + if (!$fits) { + throw ArenaException::exhausted($size, $this->size - $cursor); + } + + return $this->baseAddress + $aligned; + } + + /** + * Publishes an address under a name every process of the family can look up + * + * The directory is a fixed-size open-addressing table: it is how a child finds the + * structures the parent placed in the arena (the registry tables, an IPC ring, ...) + * without inheriting a single PHP variable. Re-registering a name overwrites it. + */ + public function putRoot(string $name, int $address): void + { + $this->assertLive(); + $nameWords = $this->encodeRootName($name); + $hash = self::hashOf($name); + $length = \strlen($name); + + $mutex = $this->mutexAt(self::ROOTS_MUTEX); + + Libc::lockMutex($mutex, self::ROOTS_MUTEX); + + $slot = $this->probeRoot($hash, $nameWords, $length); + if ($slot !== null) { + $entry = $this->rootWordIndex($slot); + + $this->words[$entry + self::ROOT_WORD_HASH] = $hash; + $this->words[$entry + self::ROOT_WORD_ADDRESS] = $address; + $this->words[$entry + self::ROOT_WORD_LENGTH] = $length; + foreach ($nameWords as $offset => $word) { + $this->words[$entry + self::ROOT_WORD_NAME + $offset] = $word; + } + } + + Libc::unlockMutex($mutex, self::ROOTS_MUTEX); + + if ($slot === null) { + throw ArenaException::rootsFull($name); + } + } + + /** + * Looks a named root up, or null when nothing was published under that name + */ + public function findRoot(string $name): ?int + { + $this->assertLive(); + $nameWords = $this->encodeRootName($name); + $hash = self::hashOf($name); + $length = \strlen($name); + + // Reads need no lock: an entry is written hash-last... but a torn read can still + // see a half-written name, so the read side takes the same mutex. It is taken for + // a handful of word loads, and lookups happen at boot, not in hot paths + $mutex = $this->mutexAt(self::ROOTS_MUTEX); + + Libc::lockMutex($mutex, self::ROOTS_MUTEX); + + $address = null; + $slot = $this->probeRoot($hash, $nameWords, $length); + if ($slot !== null) { + $entry = $this->rootWordIndex($slot); + if ($this->words[$entry + self::ROOT_WORD_HASH] !== 0) { + $address = (int) $this->words[$entry + self::ROOT_WORD_ADDRESS]; + } + } + + Libc::unlockMutex($mutex, self::ROOTS_MUTEX); + + return $address; + } + + /** + * Same as findRoot(), but a missing name is an error rather than a null + */ + public function requireRoot(string $name): int + { + return $this->findRoot($name) ?? throw ArenaException::unknownRoot($name); + } + + /** + * Every published root, name => address + * + * @return array + */ + public function roots(): array + { + $this->assertLive(); + + $mutex = $this->mutexAt(self::ROOTS_MUTEX); + + Libc::lockMutex($mutex, self::ROOTS_MUTEX); + + /** @var array}> $raw */ + $raw = []; + for ($slot = 0; $slot < self::ROOT_CAPACITY; $slot++) { + $entry = $this->rootWordIndex($slot); + if ($this->words[$entry + self::ROOT_WORD_HASH] === 0) { + continue; + } + $nameWords = []; + for ($word = 0; $word < self::ROOT_NAME_SIZE / 8; $word++) { + $nameWords[] = (int) $this->words[$entry + self::ROOT_WORD_NAME + $word]; + } + $raw[] = [ + (int) $this->words[$entry + self::ROOT_WORD_ADDRESS], + (int) $this->words[$entry + self::ROOT_WORD_LENGTH], + $nameWords, + ]; + } + + Libc::unlockMutex($mutex, self::ROOTS_MUTEX); + + $roots = []; + foreach ($raw as [$address, $length, $nameWords]) { + $roots[self::decodeRootName($nameWords, $length)] = $address; + } + + return $roots; + } + + /** + * Takes one of the consumer stripe mutexes (indexes FIRST_STRIPE .. MUTEX_COUNT - 1) + * + * Only memory operations are allowed until the matching unlockStripe() - see the class + * docblock. The return value reports whether the lock was recovered from a process that + * died holding it, in which case the guarded structure has to be checked by the caller. + * + * @return bool Whether the previous owner died holding this lock + */ + public function lockStripe(int $index): bool + { + return Libc::lockMutex($this->stripeAt($index), $index); + } + + /** + * @param bool|null $recovered Set to whether the acquired lock came from a died owner + * + * @return bool Whether the lock was taken; false means somebody else holds it + */ + public function tryLockStripe(int $index, ?bool &$recovered = null): bool + { + return Libc::tryLockMutex($this->stripeAt($index), $index, $recovered); + } + + public function unlockStripe(int $index): void + { + Libc::unlockMutex($this->stripeAt($index), $index); + } + + /** + * Picks the stripe mutex that guards the structure living at $address + * + * Striping by address is what lets an unbounded number of small shared structures share + * a bank of 62 locks: unrelated structures usually land on different stripes, and two + * that collide are merely serialized against each other, never corrupted. The shift + * drops the bits every arena block has in common (allocations are at least 16-aligned), + * so neighbouring blocks do not all pile onto one stripe. + */ + public function stripeFor(int $address): int + { + return self::FIRST_STRIPE + (($address >> 4) & PHP_INT_MAX) % $this->stripeCount(); + } + + /** + * Reserves a robust process-shared mutex of its OWN inside the payload + * + * The bank has 62 consumer stripes, which is the right shape for many small structures + * sharing a few locks (see stripeFor()) and the wrong shape for a structure whose lock is + * held on every operation - a channel ring, a slot table. Those allocate their mutex here + * instead: it costs one cache line of payload, it is initialized ONCE by the process that + * creates the structure (before any worker forks, or under the allocator lock afterwards), + * and it means two unrelated channels can never serialize against each other. + * + * The returned address is stored in the owning structure's header, so a process that + * attaches later finds the lock through the arena rather than through inherited state. + * + * @return int Address of the initialized mutex, valid in every process of the family + */ + public function allocateMutex(): int + { + $address = $this->allocate(self::MUTEX_SLOT_SIZE, self::MUTEX_ALIGNMENT); + Libc::initSharedMutex($this->mutexPointerAt($address)); + + return $address; + } + + /** + * Takes a dedicated mutex; see lockStripe() for the locking rules that apply while held + * + * @return bool Whether the previous owner died holding this lock (EOWNERDEAD, recovered) + */ + public function lockMutexAt(int $address): bool + { + return Libc::lockMutex($this->mutexPointerAt($address), $address); + } + + /** + * @param bool|null $recovered Set to whether the acquired lock came from a died owner + * + * @return bool Whether the lock was taken; false means somebody else holds it right now + */ + public function tryLockMutexAt(int $address, ?bool &$recovered = null): bool + { + return Libc::tryLockMutex($this->mutexPointerAt($address), $address, $recovered); + } + + public function unlockMutexAt(int $address): void + { + Libc::unlockMutex($this->mutexPointerAt($address), $address); + } + + /** + * Verifies that the mapping still is the arena this build knows how to read + * + * Cheap, and the natural first thing a recovering worker does: the magic proves the + * region is an arena at all (rather than a stale address or a mapping that was replaced), + * and the layout version proves the offsets of the mutex bank and the roots directory + * are the ones this build compiles against. A mismatch is a hard failure - reading a + * foreign layout would mean locking bytes that are somebody else's data. + */ + public function assertIntact(): void + { + $this->assertLive(); + + $magic = (int) $this->words[self::WORD_MAGIC]; + if ($magic !== self::MAGIC) { + throw ArenaException::notAnArena($magic); + } + $version = (int) $this->words[self::WORD_LAYOUT_VERSION]; + if ($version !== self::LAYOUT_VERSION) { + throw ArenaException::layoutMismatch($version, self::LAYOUT_VERSION); + } + } + + /** + * Whether a whole address range lies inside this arena's payload + * + * The bounds check behind every "is this structure still shared?" question: an engine + * data block that has been reallocated into a worker's private heap answers false, and + * that pointer change is the ONLY observable symptom of the resize - the engine writes + * the new address into the shared struct before it aborts, so surviving siblings would + * otherwise read plausible garbage with no signal at all. + */ + public function contains(int $address, int $length = 1): bool + { + if ($this->released || $length < 0) { + return false; + } + $offset = $address - $this->baseAddress; + + return $offset >= self::HEADER_SIZE && $offset + $length <= $this->size; + } + + /** + * Reads one aligned 64-bit word of arena payload + */ + public function readWord(int $address): int + { + $this->assertRange($address, 8); + if (($address & 7) !== 0) { + throw ArenaException::misalignedAddress($address); + } + + return (int) $this->words[($address - $this->baseAddress) >> 3]; + } + + /** + * Writes one aligned 64-bit word of arena payload + */ + public function writeWord(int $address, int $value): void + { + $this->assertRange($address, 8); + if (($address & 7) !== 0) { + throw ArenaException::misalignedAddress($address); + } + + $this->words[($address - $this->baseAddress) >> 3] = $value; + } + + /** + * Copies $length bytes of arena payload out as a PHP string + */ + public function readBytes(int $address, int $length): string + { + $this->assertRange($address, $length); + + return FFI::string($this->pointerAt($address - $this->baseAddress), $length); + } + + /** + * Copies a PHP string into arena payload; returns the number of bytes written + */ + public function writeBytes(int $address, string $bytes): int + { + $length = \strlen($bytes); + $this->assertRange($address, $length); + if ($length > 0) { + FFI::memcpy($this->pointerAt($address - $this->baseAddress), $bytes, $length); + } + + return $length; + } + + /** + * Unmaps the arena - the creating process only, and only once + * + * A child calling this is a deliberate no-op rather than an error: a child unmapping the + * region would tear the arena out from under its parent and siblings, and its own copy of + * the mapping goes away with the process anyway. + * + * ## Why this is NOT armed as a shutdown function + * + * It used to be, and it was a SIGSEGV waiting for the right test order. Request shutdown + * runs registered functions first and destroys the symbol table, the object store and + * every remaining zval AFTERWARDS - so any variable still holding a shared object (a + * global, a static, a store that has not detached yet) is released against memory that is + * no longer mapped. The mapping is necessarily created before any of them, and shutdown + * functions run in registration order, so no amount of ordering inside this class can put + * the unmap last. + * + * The arena is process-scoped by design (created once, before the workers fork), and a + * mapping is reclaimed by the kernel when the process exits - which is exactly the + * lifetime the leak-until-teardown model already assumes. So the automatic unmap is gone + * and this method stays available for a caller who genuinely owns the moment: nothing may + * reference the arena anymore when it is called - no attached store, no shared instance, + * no IPC structure. + */ + public function destroy(): void + { + if ($this->released || !$this->isCreator()) { + return; + } + $this->released = true; + $this->mutexes = []; + $this->ownedMutexes = []; + + Libc::unmap($this->base, $this->size); + } + + /** + * Binds the two pointer views over a fresh mapping (once per process, at map time) + */ + private function bindViews(CData $mapping): void + { + $this->base = $mapping; + $this->words = Libc::ffi()->cast('uint64_t *', $mapping); + } + + private function cursor(): int + { + return (int) $this->words[self::WORD_CURSOR]; + } + + /** + * char* at a byte offset into the mapping + */ + private function pointerAt(int $offset): CData + { + return FFI::addr($this->base[$offset]); + } + + /** + * char* of one mutex slot, materialized once and cached for the process lifetime + */ + private function mutexAt(int $index): CData + { + return $this->mutexes[$index] ??= $this->pointerAt(self::MUTEX_OFFSET + $index * self::MUTEX_SLOT_SIZE); + } + + /** + * char* of a dedicated payload mutex, materialized once per process and cached + * + * The cache matters as much as the bounds check: a CData built per lock would allocate + * inside (or immediately before) a critical section, which is exactly what the locking + * rules forbid. + */ + private function mutexPointerAt(int $address): CData + { + if (isset($this->ownedMutexes[$address])) { + return $this->ownedMutexes[$address]; + } + $this->assertRange($address, self::MUTEX_SLOT_SIZE); + if ($address % self::MUTEX_ALIGNMENT !== 0) { + throw ArenaException::misalignedAddress($address); + } + + return $this->ownedMutexes[$address] = $this->pointerAt($address - $this->baseAddress); + } + + private function stripeAt(int $index): CData + { + $this->assertLive(); + if ($index < self::FIRST_STRIPE || $index >= self::MUTEX_COUNT) { + throw ArenaException::invalidMutexIndex($index); + } + + return $this->mutexAt($index); + } + + /** + * Word index of the first word of roots-directory slot $slot + */ + private function rootWordIndex(int $slot): int + { + return (self::ROOTS_OFFSET + $slot * self::ROOT_ENTRY_SIZE) >> 3; + } + + /** + * Finds the slot a name lives in, or the first free slot it may be written to + * + * Called with the roots mutex held: word loads only, no allocation, no throwing. + * + * @param list $nameWords + * + * @return int|null Slot index, or null when the table is full + */ + private function probeRoot(int $hash, array $nameWords, int $length): ?int + { + $start = ($hash & PHP_INT_MAX) % self::ROOT_CAPACITY; + for ($probe = 0; $probe < self::ROOT_CAPACITY; $probe++) { + $slot = ($start + $probe) % self::ROOT_CAPACITY; + $entry = $this->rootWordIndex($slot); + if ($this->words[$entry + self::ROOT_WORD_HASH] === 0) { + return $slot; + } + if ($this->words[$entry + self::ROOT_WORD_HASH] !== $hash) { + continue; + } + if ($this->words[$entry + self::ROOT_WORD_LENGTH] !== $length) { + continue; + } + $same = true; + foreach ($nameWords as $offset => $word) { + if ($this->words[$entry + self::ROOT_WORD_NAME + $offset] !== $word) { + $same = false; + + break; + } + } + if ($same) { + return $slot; + } + } + + return null; + } + + /** + * Packs a root name into the five words the directory entry stores it in + * + * Packing happens OUTSIDE the roots mutex on purpose: the critical section may only + * move words around, and a name comparison done word-wise needs no string handling + * inside the lock at all. + * + * @return list + */ + private function encodeRootName(string $name): array + { + if (\strlen($name) > self::ROOT_NAME_SIZE) { + throw ArenaException::rootNameTooLong($name); + } + $padded = str_pad($name, self::ROOT_NAME_SIZE, "\0"); + + $words = []; + for ($offset = 0; $offset < self::ROOT_NAME_SIZE; $offset += 8) { + /** @var array{1: int} $unpacked */ + $unpacked = unpack('P', substr($padded, $offset, 8)); + $words[] = $unpacked[1]; + } + + return $words; + } + + /** + * @param list $words + */ + private static function decodeRootName(array $words, int $length): string + { + $name = ''; + foreach ($words as $word) { + $name .= pack('P', $word); + } + + return substr($name, 0, $length); + } + + /** + * Stable, non-zero hash of a root name (zero marks a free directory slot) + */ + private static function hashOf(string $name): int + { + return crc32($name) | 1; + } + + /** + * Rejects an address range that is not inside this arena's PAYLOAD + * + * The header is deliberately excluded: cursor, mutexes and the roots directory are the + * arena's own bookkeeping and are only ever touched by the methods above. + */ + private function assertRange(int $address, int $length): void + { + $this->assertLive(); + $offset = $address - $this->baseAddress; + if ($length < 0 || $offset < self::HEADER_SIZE || $offset + $length > $this->size) { + throw ArenaException::outOfBounds($address, $length); + } + } + + private function assertLive(): void + { + if ($this->released) { + throw ArenaException::released(); + } + } +} diff --git a/src/Shm/ArenaAllocator.php b/src/Shm/ArenaAllocator.php new file mode 100644 index 0000000..b80bad2 --- /dev/null +++ b/src/Shm/ArenaAllocator.php @@ -0,0 +1,114 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use ZEngine\Memory\Allocator; +use ZEngine\Type\PersistentHashTable; + +/** + * The arena, seen through z-engine's allocator seam + * + * z-engine mints its persistent primitives (hashtable structs, object clones, interned + * string blocks) through a ZEngine\Memory\Allocator. The default one is malloc-backed and + * therefore PROCESS-LOCAL: memory a worker allocates after the fork is invisible to its + * parent and its siblings, which is exactly what stops persisted objects from being shared. + * This adapter answers the same interface out of the fork-shared arena instead, so a + * structure built through it lives at an address that means the same thing in every + * process of the family. + * + * Two properties of the arena make it a legal Allocator: + * + * - blocks come back ZEROED, because the bump allocator never recycles: every address it + * hands out is untouched mmap memory, which the kernel guarantees to be zero-filled; + * - ownsAllocations() is true, so z-engine never frees an individual block through its own + * allocator. Arena memory is reclaimed as one region when the creating process exits - + * a structure built here refuses its destroy() path instead of calling free(3) on an + * address the process heap knows nothing about. + */ +final class ArenaAllocator implements Allocator +{ + public function __construct(private readonly Arena $arena) + { + } + + /** + * The arena this allocator hands memory out of + */ + public function arena(): Arena + { + return $this->arena; + } + + /** + * @inheritDoc + */ + #[\Override] + public function allocate(int $size, int $alignment = Allocator::DEFAULT_ALIGNMENT): int + { + // Never below malloc's guarantee: z-engine asks for ENGINE_STRUCT_ALIGNMENT (8), + // which is all a zend_object needs, but keeping every arena block 16-aligned costs + // nothing in a bump allocator and keeps engine structures aligned the way the + // process heap would have aligned them + return $this->arena->allocate($size, max($alignment, Allocator::DEFAULT_ALIGNMENT)); + } + + /** + * The arena owns every block it hands out: z-engine must never free one + * + * @inheritDoc + */ + #[\Override] + public function ownsAllocations(): bool + { + return true; + } + + /** + * Mints an arena-resident hashtable whose bucket storage is pre-sized and NEVER grown + * + * Both halves of a shared table come from the arena: the struct through this allocator, + * the bucket block through externalStorageSize()/withExternalStorage(). Pre-sizing is + * not an optimization but the whole point - the engine grows a full table by + * perealloc()ing its data block, and for arena memory that call would hand the block + * to the process heap of whichever worker happened to trigger it. A table installed + * with external storage refuses the insert that would start the growth instead, with + * z-engine's typed storageCapacityExhausted() exception. + * + * @param int $capacity Number of buckets to reserve; rounded up to the power of two + * the engine addresses tables in + */ + public function createTable(int $capacity): PersistentHashTable + { + $capacity = self::bucketCapacity($capacity); + $address = $this->allocate( + PersistentHashTable::externalStorageSize($capacity), + Allocator::ENGINE_STRUCT_ALIGNMENT, + ); + + return PersistentHashTable::withExternalStorage($address, $capacity, $this); + } + + /** + * Rounds a wanted number of buckets up to a power of two of at least HT_MIN_SIZE + */ + public static function bucketCapacity(int $wanted): int + { + $capacity = ArenaRegistryLayout::MINIMUM_TABLE_CAPACITY; + while ($capacity < $wanted) { + $capacity <<= 1; + } + + return $capacity; + } +} diff --git a/src/Shm/ArenaException.php b/src/Shm/ArenaException.php new file mode 100644 index 0000000..c60effe --- /dev/null +++ b/src/Shm/ArenaException.php @@ -0,0 +1,221 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +/** + * Every way the fork-shared arena can refuse to work + * + * Failure modes are named constructors, never hand-written messages at the call site: + * the arena is memory that several processes share, so a message must always say WHICH + * limit was hit and by how much - that wording belongs in one place. + */ +final class ArenaException extends \RuntimeException +{ + public static function unsupportedPlatform(string $platform): self + { + return new self(sprintf( + 'The shared arena is Linux-only (mmap MAP_ANONYMOUS|MAP_SHARED plus robust ' . + 'process-shared pthread mutexes); this platform is %s', + $platform, + )); + } + + public static function libcUnavailable(string $reason): self + { + return new self("Cannot bind libc through FFI for the shared arena: {$reason}"); + } + + public static function mappingFailed(int $size): self + { + return new self(sprintf('mmap() of %d bytes of shared anonymous memory failed', $size)); + } + + public static function invalidSize(int $size): self + { + return new self(sprintf( + 'Arena size must be a positive multiple of the page size that leaves room for the ' . + '%d byte header, got %d', + Arena::HEADER_SIZE, + $size, + )); + } + + public static function exhausted(int $requested, int $remaining): self + { + return new self(sprintf( + 'Shared arena exhausted: %d bytes requested, %d bytes left. The arena is fixed-size ' . + 'and bump-allocated (blocks are never returned); raise SHARED_DATA_ARENA_SIZE before ' . + 'the arena is created - it cannot grow once processes have forked.', + $requested, + $remaining, + )); + } + + public static function invalidAlignment(int $alignment): self + { + return new self(sprintf('Allocation alignment must be a power of two up to 4096, got %d', $alignment)); + } + + public static function outOfBounds(int $address, int $length): self + { + return new self(sprintf( + 'Address range [0x%x, 0x%x) is outside this arena', + $address, + $address + $length, + )); + } + + public static function misalignedAddress(int $address): self + { + return new self(sprintf('Address 0x%x is not 8-byte aligned; word access would tear', $address)); + } + + public static function rootNameTooLong(string $name): self + { + return new self(sprintf( + 'Named root "%s" is %d bytes long, the directory stores at most %d', + $name, + \strlen($name), + Arena::ROOT_NAME_SIZE, + )); + } + + public static function rootsFull(string $name): self + { + return new self(sprintf( + 'The arena roots directory is full (%d entries); cannot register "%s"', + Arena::ROOT_CAPACITY, + $name, + )); + } + + public static function unknownRoot(string $name): self + { + return new self("The arena roots directory has no entry named \"{$name}\""); + } + + public static function mutexSlotTooSmall(int $probed, int $slotSize): self + { + return new self(sprintf( + 'This platform reports a %d byte pthread_mutex_t, the arena reserves %d bytes per slot', + $probed, + $slotSize, + )); + } + + public static function invalidMutexIndex(int $index): self + { + return new self(sprintf( + 'Mutex index %d is out of range; the arena carries %d slots and reserves indexes 0..%d', + $index, + Arena::MUTEX_COUNT, + Arena::FIRST_STRIPE - 1, + )); + } + + public static function mutexOperationFailed(string $operation, int $index, int $errorCode): self + { + return new self(sprintf( + '%s on the arena mutex at slot/address %d failed with error %d; the shared lock state is unusable', + $operation, + $index, + $errorCode, + )); + } + + public static function layoutMismatch(int $found, int $expected): self + { + return new self(sprintf( + 'The mapped arena carries layout version %d, this build speaks %d', + $found, + $expected, + )); + } + + public static function invalidRegistryCapacity(int $entryCapacity, int $objectCapacity): self + { + return new self(sprintf( + 'Arena registry capacities must be whole numbers of at least %d, got %d entries and %d objects', + ArenaRegistryLayout::MINIMUM_TABLE_CAPACITY, + $entryCapacity, + $objectCapacity, + )); + } + + public static function registryTableFull(string $table, int $capacity): self + { + return new self(sprintf( + 'The arena registry table "%s" is full: all %d bucket slots are used, and growing it ' . + 'would make the engine reallocate arena memory into this worker\'s private heap. ' . + 'Size the registry with %s / %s before the workers fork.', + $table, + $capacity, + ArenaRegistryLayout::ENTRY_CAPACITY_ENV, + ArenaRegistryLayout::OBJECT_CAPACITY_ENV, + )); + } + + public static function registryTableRelocated(string $table, int $dataAddress): self + { + return new self(sprintf( + 'The bucket storage of the arena registry table "%s" now lives at 0x%x, outside the ' . + 'arena: the engine has grown the table into a private heap, and every process but the ' . + 'one that grew it is looking at memory that is not shared (and may already be freed). ' . + 'The registry is unusable - restart the worker pool with a larger capacity.', + $table, + $dataAddress, + )); + } + + public static function foreignArena(int $expected, int $found): self + { + return new self(sprintf( + 'The persistent module is anchored to the arena at 0x%x, but 0x%x was passed; a worker ' . + 'can only attach the arena its module globals were written for', + $expected, + $found, + )); + } + + /** + * A free path was reached with a block that lives in the fork-shared arena + * + * The arena is bump-allocated: blocks are never handed back one by one, and the region + * dies as a whole with the process that created it. A free() here would be a free() of + * memory this process's heap never allocated - and in a forked CHILD it would additionally + * be a free() of memory the parent and every sibling are still reading. Both are refused + * before anything is released rather than diagnosed afterwards. + */ + public static function blockNotFreeable(string $what, int $address, bool $isCreator): self + { + return new self(sprintf( + 'Refusing to free the %s at 0x%x: it lives in the fork-shared arena, which is bump-allocated ' . + 'and reclaimed only when the creating process exits%s. Arena-backed graphs release their ' . + 'registry bookkeeping without freeing memory - see Registry::removeObject().', + $what, + $address, + $isCreator ? '' : ', and this process is not the one that created it', + )); + } + + public static function released(): self + { + return new self('This arena has already been unmapped by its creating process'); + } + + public static function notAnArena(int $magic): self + { + return new self(sprintf('The mapped region does not start with the arena magic (found 0x%x)', $magic)); + } +} diff --git a/src/Shm/ArenaRegistryLayout.php b/src/Shm/ArenaRegistryLayout.php new file mode 100644 index 0000000..462217f --- /dev/null +++ b/src/Shm/ArenaRegistryLayout.php @@ -0,0 +1,93 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +/** + * How much room the registry reserves in the arena, and under which names it is published + * + * Every table of an arena-resident registry is PRE-SIZED at creation, because the engine + * grows a full hashtable by reallocating its data block - which, for a block inside the + * arena, would silently move shared state into one worker's private heap. Capacity is + * therefore a deployment decision, taken once before the workers fork, and hitting it is a + * clean typed failure rather than corruption. + * + * The names are what a child uses to FIND the registry: a forked worker inherits the arena + * mapping and nothing else, so it looks the root tables up in the arena's own roots + * directory instead of relying on any inherited PHP value. + */ +final class ArenaRegistryLayout +{ + /** + * HT_MIN_SIZE: the smallest bucket count the engine addresses a table with + */ + public const int MINIMUM_TABLE_CAPACITY = 8; + + /** + * Roots-directory names of the registry tables + */ + public const string ROOT_TABLE = 'registry.root'; + public const string ROOT_ENTRIES = 'registry.entries'; + public const string ROOT_OBJECTS = 'registry.objects'; + + /** + * Named graphs (persist() keys) an arena registry can hold + */ + public const int DEFAULT_ENTRY_CAPACITY = 256; + + /** + * Object clones an arena registry can hold across all of its graphs + */ + public const int DEFAULT_OBJECT_CAPACITY = 4096; + + public const string ENTRY_CAPACITY_ENV = 'SHARED_DATA_ENTRY_CAPACITY'; + public const string OBJECT_CAPACITY_ENV = 'SHARED_DATA_OBJECT_CAPACITY'; + + /** + * Records are tiny fixed-shape tables (an entry record has 2 keys, an object record 6) + */ + public const int RECORD_CAPACITY = self::MINIMUM_TABLE_CAPACITY; + + public function __construct( + public readonly int $entryCapacity = self::DEFAULT_ENTRY_CAPACITY, + public readonly int $objectCapacity = self::DEFAULT_OBJECT_CAPACITY, + ) { + if ($entryCapacity < self::MINIMUM_TABLE_CAPACITY || $objectCapacity < self::MINIMUM_TABLE_CAPACITY) { + throw ArenaException::invalidRegistryCapacity($entryCapacity, $objectCapacity); + } + } + + /** + * Reads the capacities from the environment, so a deployment can size them without code + */ + public static function fromEnvironment(): self + { + return new self( + self::readCapacity(self::ENTRY_CAPACITY_ENV, self::DEFAULT_ENTRY_CAPACITY), + self::readCapacity(self::OBJECT_CAPACITY_ENV, self::DEFAULT_OBJECT_CAPACITY), + ); + } + + private static function readCapacity(string $variable, int $default): int + { + $configured = getenv($variable); + if ($configured === false || trim($configured) === '') { + return $default; + } + if (preg_match('/^\d+$/', trim($configured)) !== 1) { + throw ArenaException::invalidRegistryCapacity(0, 0); + } + + return (int) trim($configured); + } +} diff --git a/src/Shm/Libc.php b/src/Shm/Libc.php new file mode 100644 index 0000000..b013c89 --- /dev/null +++ b/src/Shm/Libc.php @@ -0,0 +1,300 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use FFI; +use FFI\CData; + +/** + * The package's own, minimal binding to the two libc facilities the arena is built on + * + * `FFI::cdef($code, null)` declares symbols without naming a shared object: on Linux the + * lookup goes through `RTLD_DEFAULT`, which resolves against the already-loaded libc of + * the running interpreter (same technique z-engine uses to reach the engine's own + * symbols). Nothing is dlopen()ed, no header is generated, and z-engine's generated + * engine definitions are left completely alone - the arena is deliberately NOT an engine + * structure, so it has no business in engine headers. + * + * Two facilities, and only these two: + * + * - `mmap`/`munmap` with `MAP_SHARED|MAP_ANONYMOUS`, which is what makes one region of + * memory visible at the SAME address in every process forked from the creator; + * - `pthread_mutex_*` with `PTHREAD_PROCESS_SHARED` and `PTHREAD_MUTEX_ROBUST` + * attributes, the only cross-process synchronization primitive reachable from PHP + * (FFI offers no atomics, no CAS and no fences). ROBUST is what keeps a killed worker + * from wedging the whole pool: the next locker is told the owner died (`EOWNERDEAD`) + * and may declare the state consistent again. + * + * All constants below are the Linux/x86-64 (and arm64 - they agree) ABI values. The class + * is internal: it hands out `CData`, which no public API of this package ever does. + * + * @internal + */ +final class Libc +{ + public const int PROT_READ = 0x1; + public const int PROT_WRITE = 0x2; + + public const int MAP_SHARED = 0x01; + public const int MAP_ANONYMOUS = 0x20; + + public const int PTHREAD_PROCESS_SHARED = 1; + public const int PTHREAD_MUTEX_ROBUST = 1; + + /** + * The errno values the mutex calls answer with that are not failures + */ + public const int EBUSY = 16; + public const int EOWNERDEAD = 130; + public const int ENOTRECOVERABLE = 131; + + /** + * Size of the scratch buffer a pthread_mutexattr_t is built in (it is 4 bytes on + * glibc; 64 is "generously more than any libc will ever need" and costs one + * request-lifetime allocation per process) + */ + private const int ATTR_BUFFER_SIZE = 64; + + /** + * Byte pattern the mutex-size probe paints its buffer with before pthread_mutex_init() + * + * Any value that is not a byte a fresh mutex may legitimately contain works; 0xAA is + * the traditional "definitely not zero, definitely not a pointer" filler. + */ + private const int PROBE_FILL = 0xAA; + + private const int PROBE_BUFFER_SIZE = 512; + + private static ?FFI $ffi = null; + + private static ?int $mutexSize = null; + + private function __construct() + { + } + + /** + * Binds (once per process) the libc symbols the arena needs + */ + public static function ffi(): FFI + { + if (self::$ffi !== null) { + return self::$ffi; + } + if (PHP_OS_FAMILY !== 'Linux') { + throw ArenaException::unsupportedPlatform(PHP_OS_FAMILY); + } + + try { + // No library name: RTLD_DEFAULT resolves these against the loaded libc + $ffi = FFI::cdef( + <<<'C' + void *mmap(void *addr, size_t length, int prot, int flags, int fd, int64_t offset); + int munmap(void *addr, size_t length); + int pthread_mutexattr_init(void *attr); + int pthread_mutexattr_setpshared(void *attr, int pshared); + int pthread_mutexattr_setrobust(void *attr, int robustness); + int pthread_mutexattr_destroy(void *attr); + int pthread_mutex_init(void *mutex, void *attr); + int pthread_mutex_destroy(void *mutex); + int pthread_mutex_lock(void *mutex); + int pthread_mutex_trylock(void *mutex); + int pthread_mutex_unlock(void *mutex); + int pthread_mutex_consistent(void *mutex); + C, + null, + ); + } catch (FFI\Exception $exception) { + throw ArenaException::libcUnavailable($exception->getMessage()); + } + + return self::$ffi = $ffi; + } + + /** + * Maps a fresh region of shared anonymous memory, visible to every later fork + * + * @return CData char* at the start of the mapping + */ + public static function mapShared(int $size): CData + { + $ffi = self::ffi(); + $mapping = $ffi->mmap( + null, + $size, + self::PROT_READ | self::PROT_WRITE, + self::MAP_SHARED | self::MAP_ANONYMOUS, + -1, + 0, + ); + \assert($mapping instanceof CData); + + // MAP_FAILED is (void *) -1; a null return is impossible for a successful mmap + if (FFI::isNull($mapping) || self::addressOf($mapping) === -1) { + throw ArenaException::mappingFailed($size); + } + + return $ffi->cast('char *', $mapping); + } + + public static function unmap(CData $mapping, int $size): void + { + self::ffi()->munmap($mapping, $size); + } + + /** + * Numeric address of a pointer + * + * `FFI::cast('uintptr_t', $pointer)` cannot be used for this: on a pointer CData it + * reinterprets the POINTEE, so a freshly mapped (zero-filled) region answers 0. The + * pointer value itself is read by storing it into a one-element pointer array and + * viewing that array's storage as an integer. + */ + public static function addressOf(CData $pointer): int + { + $ffi = self::ffi(); + $holder = $ffi->new('void *[1]'); + $holder[0] = $ffi->cast('void *', $pointer); + + return (int) $ffi->cast('uint64_t[1]', $holder)[0]; + } + + /** + * Initializes one PTHREAD_PROCESS_SHARED + PTHREAD_MUTEX_ROBUST mutex at $mutex + */ + public static function initSharedMutex(CData $mutex): void + { + $ffi = self::ffi(); + $attr = $ffi->new('char[' . self::ATTR_BUFFER_SIZE . ']'); + FFI::memset($attr, 0, self::ATTR_BUFFER_SIZE); + + $code = $ffi->pthread_mutexattr_init($attr); + if ($code === 0) { + $code = $ffi->pthread_mutexattr_setpshared($attr, self::PTHREAD_PROCESS_SHARED); + } + if ($code === 0) { + $code = $ffi->pthread_mutexattr_setrobust($attr, self::PTHREAD_MUTEX_ROBUST); + } + if ($code === 0) { + $code = $ffi->pthread_mutex_init($mutex, $attr); + } + $ffi->pthread_mutexattr_destroy($attr); + + if ($code !== 0) { + throw ArenaException::mutexOperationFailed('pthread_mutex_init', -1, $code); + } + } + + /** + * Measures sizeof(pthread_mutex_t) without a compiler, once per process + * + * There is no portable way to ask libc for the size of an opaque type from PHP, and a + * hard-coded 40 (x86-64 glibc) would be a silent buffer overlap on a platform that + * disagrees. So the size is measured: a buffer is painted with a filler byte, a mutex + * is initialized into it, and the highest byte the initialization touched is the size. + * glibc's pthread_mutex_init() clears the whole structure, which makes the answer + * exact; a libc that writes only some fields yields a LOWER BOUND, which is why the + * result is floored at the known x86-64/arm64 glibc size and the caller still verifies + * it against the arena's slot stride. + */ + public static function probeMutexSize(): int + { + if (self::$mutexSize !== null) { + return self::$mutexSize; + } + + $buffer = self::ffi()->new('char[' . self::PROBE_BUFFER_SIZE . ']'); + FFI::memset($buffer, self::PROBE_FILL, self::PROBE_BUFFER_SIZE); + self::initSharedMutex($buffer); + + $touched = 0; + for ($index = 0; $index < self::PROBE_BUFFER_SIZE; $index++) { + if (self::ffi()->cast('unsigned char', $buffer[$index])->cdata !== self::PROBE_FILL) { + $touched = $index + 1; + } + } + self::ffi()->pthread_mutex_destroy($buffer); + + return self::$mutexSize = max($touched, Arena::MUTEX_SIZE_FLOOR); + } + + /** + * Takes one robust process-shared mutex, recovering a lock whose owner died + * + * `EOWNERDEAD` means the previous owner exited while holding the lock. The arena's own + * critical sections are single aligned word updates, so the protected state cannot be + * torn - declaring it consistent again and carrying on is exactly right here. Any + * consumer that guards a multi-word structure with a stripe mutex has to make its own + * consistency decision, which is why the recovery is reported through the return value. + * + * @return bool Whether the lock was recovered from a died owner + */ + public static function lockMutex(CData $mutex, int $index): bool + { + $ffi = self::ffi(); + $code = $ffi->pthread_mutex_lock($mutex); + if ($code === 0) { + return false; + } + if ($code === self::EOWNERDEAD) { + $recovered = $ffi->pthread_mutex_consistent($mutex); + if ($recovered !== 0) { + throw ArenaException::mutexOperationFailed('pthread_mutex_consistent', $index, $recovered); + } + + return true; + } + + throw ArenaException::mutexOperationFailed('pthread_mutex_lock', $index, $code); + } + + /** + * @param bool|null $recovered Set to whether the acquired lock came from a died owner; + * the trylock path answers the EOWNERDEAD question through + * this out parameter, so no lock result is ever discarded + * + * @return bool Whether the lock was taken (false = held by somebody else right now) + */ + public static function tryLockMutex(CData $mutex, int $index, ?bool &$recovered = null): bool + { + $recovered = false; + $ffi = self::ffi(); + $code = $ffi->pthread_mutex_trylock($mutex); + if ($code === self::EBUSY) { + return false; + } + if ($code === self::EOWNERDEAD) { + $consistent = $ffi->pthread_mutex_consistent($mutex); + if ($consistent !== 0) { + throw ArenaException::mutexOperationFailed('pthread_mutex_consistent', $index, $consistent); + } + $recovered = true; + + return true; + } + if ($code !== 0) { + throw ArenaException::mutexOperationFailed('pthread_mutex_trylock', $index, $code); + } + + return true; + } + + public static function unlockMutex(CData $mutex, int $index): void + { + $code = self::ffi()->pthread_mutex_unlock($mutex); + if ($code !== 0) { + throw ArenaException::mutexOperationFailed('pthread_mutex_unlock', $index, $code); + } + } +} diff --git a/src/SideTable.php b/src/SideTable.php new file mode 100644 index 0000000..3b4721f --- /dev/null +++ b/src/SideTable.php @@ -0,0 +1,131 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData; + +use FFI\CData; + +/** + * The per-process half of a shared object, kept OUT of the shared struct + * + * A `zend_object` in the arena is one struct read by several processes, and three of its + * fields describe the READER rather than the object. Leaving them in shared memory is what + * the validation sweep measured, and each one fails differently (docs/shared-memory-model.md, + * §3): + * + * - `handle` collides BY CONSTRUCTION. Forked children inherit one object-store free list, + * so two children registering two different objects are handed the very same number, and + * each one overwrites the other's handle inside the shared struct. A later detach then + * recycles a slot that belongs to somebody else's object; + * - `ce` is only fork-stable for classes loaded before the fork; a class first autoloaded + * inside one worker lands at an address no sibling can follow; + * - `properties` is written by ENGINE C CODE on read-shaped operations (`var_dump()`, + * `get_object_vars()`, `json_encode()`, `(array)`, `serialize()`, `debug_zval_dump()`, + * `ReflectionObject`) - a request-heap pointer deposited in shared memory, which a + * sibling dereferences at its peril. + * + * So the shared struct keeps only what is genuinely shareable - `handlers`, which is the + * process-lifetime `std_object_handlers` global - and everything per-process lives here, + * keyed by the one identity that means the same thing everywhere: the ARENA ADDRESS. + * + * This table is request-scoped by construction (a PHP object owned by the store, which is + * itself re-booted per request) and process-scoped by nature: a forked child inherits a copy + * and immediately overwrites it with its own registrations, because the child's object store + * is not its parent's. + */ +final class SideTable +{ + /** + * Object-store handle THIS process holds, keyed by arena address + * + * @var array + */ + private array $handles = []; + + /** + * zend_class_entry* THIS process rebound the object to, keyed by arena address + * + * @var array + */ + private array $classEntries = []; + + /** + * Records one object as registered in this process's object store + */ + public function put(int $address, int $handle, CData $classEntry): void + { + $this->handles[$address] = $handle; + $this->classEntries[$address] = $classEntry; + } + + /** + * Records the class entry this process bound an object to, without registering it + */ + public function bindClassEntry(int $address, CData $classEntry): void + { + $this->classEntries[$address] = $classEntry; + } + + public function has(int $address): bool + { + return isset($this->handles[$address]); + } + + /** + * The object-store handle of this process, or null when this process never registered it + */ + public function handleOf(int $address): ?int + { + return $this->handles[$address] ?? null; + } + + /** + * The class entry of THIS process, which is the only one an engine call may be given + */ + public function classEntryOf(int $address): ?CData + { + return $this->classEntries[$address] ?? null; + } + + /** + * Addresses this process has registered, in registration order + * + * @return list + */ + public function addresses(): array + { + return array_keys($this->handles); + } + + public function count(): int + { + return \count($this->handles); + } + + /** + * Forgets one object entirely (used when its clone is about to disappear) + */ + public function forget(int $address): void + { + unset($this->handles[$address], $this->classEntries[$address]); + } + + /** + * Drops every registration; the class bindings go with them + */ + public function clear(): void + { + $this->handles = []; + $this->classEntries = []; + } +} diff --git a/tests/Ipc/IpcTestCase.php b/tests/Ipc/IpcTestCase.php new file mode 100644 index 0000000..3248013 --- /dev/null +++ b/tests/Ipc/IpcTestCase.php @@ -0,0 +1,204 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\PersistentStore; +use Lisachenko\SharedData\Shm\Arena; +use Lisachenko\SharedData\Shm\ArenaAllocator; +use Lisachenko\SharedData\Stub\AppConfig; +use Lisachenko\SharedData\Stub\GraphNode; +use PHPUnit\Framework\TestCase; + +/** + * The harness every IPC case shares: one arena, one store, one notification plane per PROCESS + * + * Three constraints shape it, and they are all properties of the thing being tested rather + * than of PHPUnit: + * + * - **one arena per persistent module.** Module globals anchor exactly one arena for the + * lifetime of a worker, so every case here uses the same mapping (and its own module name, + * kept apart from the arena-store suite that runs in the same process). + * - **the notification plane must exist before any fork.** Its socket pairs are inherited, + * never handed over, so it is created once here and reused by every case - exactly as a + * supervisor would create it before spawning its pool. + * - **classes travelling through the arena must be loaded before the fork**, since a shared + * clone carries one class-entry pointer for the whole family. + * + * Children answer by EXIT CODE and never touch the result printer; the parent is the only + * process that asserts. Values that have to travel do so as raw bytes over a socket pair or, + * better, as an address in the arena - never as a serialized PHP value. + */ +abstract class IpcTestCase extends TestCase +{ + protected const int ARENA_SIZE = 48 << 20; + + protected const int WAKE_SLOTS = 24; + + /** + * Persistent module of this suite, so the arena-store suite in the same process keeps its own + */ + protected const string MODULE = 'shared_arena_ipc'; + + protected const int OK = 0; + protected const int WRONG_VALUE = 11; + protected const int WRONG_ORDER = 12; + protected const int WRONG_STATE = 13; + protected const int TIMED_OUT = 14; + protected const int CHILD_EXCEPTION = 40; + + private static ?Arena $arena = null; + + private static ?PersistentStore $store = null; + + private static ?ArenaAllocator $allocator = null; + + private static ?ValueCodec $codec = null; + + private static ?WakeRegistry $wake = null; + + private static ?GraphNode $sharedNode = null; + + protected function setUp(): void + { + if (!\function_exists('pcntl_fork')) { + $this->markTestSkipped('ext-pcntl is required to exercise fork-shared IPC'); + } + + // Loaded BEFORE any fork: a shared clone carries one zend_class_entry pointer for the + // whole family, and a class first autoloaded inside a child lands at an address only + // that child can follow + $this->assertTrue(class_exists(GraphNode::class)); + $this->assertTrue(class_exists(AppConfig::class)); + $this->assertTrue(class_exists(SharedError::class)); + } + + protected function arena(): Arena + { + return self::$arena ??= Arena::create(self::ARENA_SIZE); + } + + protected function store(): PersistentStore + { + return self::$store ??= PersistentStore::bootShared($this->arena(), null, self::MODULE); + } + + protected function allocator(): ArenaAllocator + { + return self::$allocator ??= new ArenaAllocator($this->arena()); + } + + protected function codec(): ValueCodec + { + return self::$codec ??= new ValueCodec($this->allocator(), $this->store()); + } + + /** + * The one notification plane of this process family, created before any case forks + */ + protected function wake(): WakeRegistry + { + return self::$wake ??= WakeRegistry::create($this->arena(), self::WAKE_SLOTS, null); + } + + /** + * The one shared object of this process, persisted once and then only ever shared + * + * Persisting is an upsert keyed by class, and releasing the superseded generation is + * refused while the request can still reach it - which is the correct behaviour and the + * wrong thing to fight in every case. Real code persists a graph once and hands its + * ADDRESS around, so the suite does the same. + */ + protected function sharedNode(): GraphNode + { + if (self::$sharedNode === null) { + $node = new GraphNode(); + $node->name = 'shared-by-address'; + $node->counter = 42; + + self::$sharedNode = $this->store()->persist(GraphNode::class, $node); + } + + return self::$sharedNode; + } + + protected function channel(int $capacity, int $waiters = SharedChannel::DEFAULT_WAITERS): SharedChannel + { + return SharedChannel::create($this->allocator(), $this->codec(), $this->wake(), $capacity, $waiters); + } + + /** + * Runs $body in a forked child and returns its pid + * + * @param callable(): int $body + */ + protected function fork(callable $body): int + { + $pid = pcntl_fork(); + $this->assertNotSame(-1, $pid, 'pcntl_fork() failed'); + + if ($pid > 0) { + return $pid; + } + + $code = self::CHILD_EXCEPTION; + + try { + $code = $body(); + } catch (\Throwable) { + // Reported as CHILD_EXCEPTION: a child must never print into the parent's run + } + + exit($code); + } + + /** + * Waits for one child and returns its exit code + */ + protected function await(int $pid): int + { + $status = 0; + pcntl_waitpid($pid, $status); + $this->assertTrue(pcntl_wifexited($status), "child {$pid} did not exit normally"); + + return pcntl_wexitstatus($status); + } + + /** + * @param list $children + */ + protected function awaitAll(array $children, string $message = 'a child disagreed'): void + { + foreach ($children as $pid) { + $this->assertSame(self::OK, $this->await($pid), $message); + } + } + + /** + * Spins until an arena word reaches $expected, so the parent can rendezvous with a child + * + * @return bool Whether the word got there before the timeout + */ + protected function awaitWord(int $address, int $expected, float $timeout = 5.0): bool + { + $deadline = microtime(true) + $timeout; + while ($this->arena()->readWord($address) !== $expected) { + if (microtime(true) >= $deadline) { + return false; + } + usleep(1_000); + } + + return true; + } +} diff --git a/tests/Ipc/NotificationPlaneForkTest.php b/tests/Ipc/NotificationPlaneForkTest.php new file mode 100644 index 0000000..67c3dae --- /dev/null +++ b/tests/Ipc/NotificationPlaneForkTest.php @@ -0,0 +1,201 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Stub\GraphNode; + +require_once __DIR__ . '/serialization-guard.php'; + +/** + * The two claims the whole epic rests on, tested rather than asserted in prose + * + * 1. the sockets between workers carry fixed 16-byte event records and NOTHING else; + * 2. no value crossing a worker boundary passes through serialize(), igbinary or JSON. + * + * Both are checked by observing the real code path: the registry's single write choke point + * for the first, namespace-local shadows of every encoding function for the second. + */ +class NotificationPlaneForkTest extends IpcTestCase +{ + public function testAnEventRecordWrittenByAChildWakesTheParentIntact(): void + { + $wake = $this->wake(); + $parentSlot = $wake->slot(); + $address = $this->arena()->allocate(16); + + $child = $this->fork(static function () use ($wake, $parentSlot, $address): int { + // The child claims a slot of its own, then pokes the parent's + $wake->slot(); + usleep(150_000); + $wake->notify($parentSlot, new WakeEvent(WakeOpcode::Result, 7, ValueTag::Obj, $address)); + + return self::OK; + }); + + $events = []; + $deadline = microtime(true) + 10.0; + while ($events === [] && microtime(true) < $deadline) { + $events = $wake->wait(1.0); + } + + $this->assertCount(1, $events, 'the parent never received the child\'s event record'); + $this->assertSame(WakeOpcode::Result, $events[0]->opcode); + $this->assertSame(7, $events[0]->id); + $this->assertSame(ValueTag::Obj, $events[0]->tag); + $this->assertSame($address, $events[0]->address, 'the address did not survive the record'); + + $this->assertSame(self::OK, $this->await($child)); + } + + public function testEveryByteThatCrossesASocketIsAFixedEventRecord(): void + { + $wake = $this->wake(); + $channel = $this->channel(4); + $slots = ResultSlotTable::create($this->allocator(), $this->codec(), $wake, 8, null); + + /** @var list $written */ + $written = []; + $wake->observeWrites(static function (int $slot, string $bytes) use (&$written): void { + $written[] = $bytes; + }); + + try { + $secret = 'a payload nobody may ever read off a socket'; + + // A full round trip with a receiver parked, so wake events really are written + $receiver = $this->fork(static function () use ($channel, $secret): int { + [$value, $ok] = $channel->recv(10.0); + + return $ok && $value === $secret ? self::OK : self::WRONG_VALUE; + }); + + usleep(150_000); + $channel->send($secret, 10.0); + $this->assertSame(self::OK, $this->await($receiver)); + + $id = $slots->allocateSlot(); + $slots->complete($id, $secret); + $slots->complete($slots->allocateSlot(), 12345); + } finally { + $wake->observeWrites(null); + } + + $this->assertNotSame([], $written, 'no notification was written at all - the test proves nothing'); + + foreach ($written as $bytes) { + $this->assertSame(WakeEvent::SIZE, \strlen($bytes), 'a socket write was not a fixed event record'); + + $event = WakeEvent::fromBytes($bytes); + $this->assertNotNull($event, 'a socket write did not parse as an event record'); + + // An event may name an ADDRESS, never a value: scalar tags carry a zero there + if ($event->tag->isAddress()) { + $this->assertTrue( + $this->arena()->contains($event->address, 8), + 'an event carried an address outside the arena', + ); + } else { + $this->assertSame(0, $event->address, 'an event carried payload bytes for a scalar value'); + } + } + + $this->assertStringNotContainsString( + 'a payload nobody may ever read off a socket', + implode('', $written), + 'value bytes leaked onto the notification socket', + ); + } + + public function testAFullProducerConsumerRoundTripCallsNoEncodingFunctionAtAll(): void + { + // First prove the guard can actually see a call: an unqualified serialize() from + // this namespace resolves to the shadow, exactly as it would from package code + SerializationGuard::reset(); + serialize('proof that the guard is wired up'); + $this->assertSame([__NAMESPACE__ . '\serialize'], SerializationGuard::calls()); + + $channel = $this->channel(8); + $array = SharedArray::create($this->allocator(), $this->codec(), 4); + $slots = ResultSlotTable::create($this->allocator(), $this->codec(), $this->wake(), 8, null); + + $shared = $this->sharedNode(); + + SerializationGuard::reset(); + + $child = $this->fork(static function () use ($channel, $slots): int { + $slotId = 0; + for ($index = 0; $index < 4; $index++) { + [$value, $ok] = $channel->recv(10.0); + if (!$ok) { + return self::TIMED_OUT; + } + if ($index === 3) { + $slots->complete($slotId, $value instanceof GraphNode ? $value->name : 'wrong'); + } + } + + return self::OK; + }); + + $slotId = $slots->allocateSlot(); + $this->assertSame(0, $slotId); + + $array[0] = 'inside the shared array'; + $channel->send('a string of real bytes', 10.0); + $channel->send(1234, 10.0); + $channel->send($array, 10.0); + $channel->send($shared, 10.0); + + $result = $slots->await($slotId, 10.0); + $this->assertSame('shared-by-address', $result->value); + $this->assertSame(self::OK, $this->await($child)); + + $this->assertSame( + [], + SerializationGuard::calls(), + 'a value was encoded on the way between processes: ' . implode(', ', SerializationGuard::calls()), + ); + } + + public function testTheRegistryRecyclesTheSlotOfAWorkerThatDied(): void + { + $wake = $this->wake(); + $wake->slot(); + + $taken = []; + for ($round = 0; $round < 3; $round++) { + $arena = $this->arena(); + $report = $arena->allocate(8); + $arena->writeWord($report, -1); + + $child = $this->fork(static function () use ($wake, $arena, $report): int { + $arena->writeWord($report, $wake->slot()); + + return self::OK; + }); + $this->assertSame(self::OK, $this->await($child)); + + $taken[] = $arena->readWord($report); + } + + // Every child took a slot, and dead workers' slots come back: a supervisor may + // respawn forever without exhausting a table sized for the pool + $this->assertCount(3, $taken); + foreach ($taken as $slot) { + $this->assertGreaterThanOrEqual(0, $slot); + $this->assertLessThan($wake->capacity(), $slot); + } + $this->assertLessThanOrEqual(2, \count(array_unique($taken)), 'dead workers never gave their slots back'); + } +} diff --git a/tests/Ipc/ResultSlotForkTest.php b/tests/Ipc/ResultSlotForkTest.php new file mode 100644 index 0000000..22fe3e4 --- /dev/null +++ b/tests/Ipc/ResultSlotForkTest.php @@ -0,0 +1,229 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\PersistentStore; +use Lisachenko\SharedData\Stub\AppConfig; + +/** + * Futures across processes: a child computes, the parent wakes on an event record and reads + * + * This is the runtime model of EPIC #15 in miniature. The child never sends a value anywhere: + * it writes a 16-byte record into a slot of the shared area and pokes the parent's socket + * with a fixed event record. The parent, parked on that socket, wakes and reads the value out + * of shared memory - for an object that means the very same zend_object, at the very same + * address, in both processes. + */ +class ResultSlotForkTest extends IpcTestCase +{ + private function slots(int $capacity = 64): ResultSlotTable + { + return ResultSlotTable::create($this->allocator(), $this->codec(), $this->wake(), $capacity, null); + } + + public function testAChildCompletesEveryTagKindAndTheParentReadsItFromSharedMemory(): void + { + $slots = $this->slots(); + $arena = $this->arena(); + $store = $this->store(); + $report = $arena->allocate(8); + $arena->writeWord($report, 0); + + $nested = SharedArray::create($this->allocator(), $this->codec(), 2); + $nested[0] = 'computed elsewhere'; + + /** @var array $ids */ + $ids = []; + foreach (['nil', 'true', 'false', 'int', 'float', 'string', 'object', 'array'] as $kind) { + $ids[$kind] = $slots->allocateSlot(); + } + + $child = $this->fork(static function () use ($slots, $store, $arena, $report, $ids, $nested): int { + $slots->complete($ids['nil'], null); + $slots->complete($ids['true'], true); + $slots->complete($ids['false'], false); + $slots->complete($ids['int'], -4242); + $slots->complete($ids['float'], 2.5); + $slots->complete($ids['string'], 'a string interned by the child'); + + // A brand-new shared object, minted AFTER the fork: nothing about it can reach + // the parent through copy-on-write + $config = new AppConfig(); + $config->env = 'from-the-child'; + $config->bootCount = 99; + $config->label = 'result'; + $config->settings = ['db' => ['port' => 5432]]; + + $shared = $store->persist(AppConfig::class, $config); + $address = $store->addressOfInstance($shared); + \assert($address !== null); + + // The address is published through shared memory, which is the only channel this + // suite ever uses for one - eight bytes, no encoding + $arena->writeWord($report, $address); + $slots->complete($ids['object'], $shared); + $slots->complete($ids['array'], $nested); + + return self::OK; + }); + + $this->assertNull($slots->await($ids['nil'], 10.0)->value); + $this->assertTrue($slots->await($ids['true'], 10.0)->value); + $this->assertFalse($slots->await($ids['false'], 10.0)->value); + $this->assertSame(-4242, $slots->await($ids['int'], 10.0)->value); + $this->assertSame(2.5, $slots->await($ids['float'], 10.0)->value); + + $string = $slots->await($ids['string'], 10.0); + $this->assertSame(ValueTag::Str, $string->tag); + $this->assertSame('a string interned by the child', $string->value); + + $object = $slots->await($ids['object'], 10.0); + $this->assertTrue($object->isDone()); + $this->assertSame(ValueTag::Obj, $object->tag); + $this->assertInstanceOf(AppConfig::class, $object->value); + $this->assertSame('from-the-child', $object->value->env); + $this->assertSame(99, $object->value->bootCount); + $this->assertSame(5432, $object->value->settings['db']['port']); + + // Zero-copy: the parent holds the object at the address the CHILD persisted it at + $this->assertSame( + $arena->readWord($report), + $store->addressOfInstance($object->value), + 'the object was rebuilt instead of shared', + ); + + $array = $slots->await($ids['array'], 10.0); + $this->assertInstanceOf(SharedArray::class, $array->value); + $this->assertSame($nested->address(), $array->value->address()); + $this->assertSame('computed elsewhere', $array->value[0]); + + $this->assertSame(self::OK, $this->await($child)); + } + + public function testTheParentParksOnTheSocketAndWakesOnTheEventRecord(): void + { + $slots = $this->slots(); + $id = $slots->allocateSlot(); + + $child = $this->fork(static function () use ($slots, $id): int { + // Long enough that the parent is certainly parked in stream_select() by now + usleep(300_000); + $slots->complete($id, 'woken by an event record'); + + return self::OK; + }); + + $before = microtime(true); + $result = $slots->await($id, 10.0); + $waited = microtime(true) - $before; + + $this->assertTrue($result->isDone()); + $this->assertSame('woken by an event record', $result->value); + $this->assertGreaterThan(0.2, $waited, 'the await returned before the child could have completed the slot'); + $this->assertLessThan(5.0, $waited, 'the await did not wake on the notification, it timed out'); + + $this->assertSame(self::OK, $this->await($child)); + } + + public function testAPanicTravelsAsASharedErrorObjectRatherThanAMessage(): void + { + $slots = $this->slots(); + $store = $this->store(); + $id = $slots->allocateSlot(); + + $child = $this->fork(static function () use ($slots, $store, $id): int { + try { + throw new \DomainException('the worker could not finish its unit of work'); + } catch (\Throwable $error) { + // The Throwable itself can never be shared - what travels is a plain + // three-string object living in the arena + $slots->completePanic($id, SharedError::capture($store, $error)); + } + + return self::OK; + }); + + $result = $slots->await($id, 10.0); + + $this->assertTrue($result->isPanic()); + $this->assertSame(ValueTag::Obj, $result->tag); + $this->assertInstanceOf(SharedError::class, $result->value); + $this->assertSame(\DomainException::class, $result->value->className); + $this->assertSame('the worker could not finish its unit of work', $result->value->message); + $this->assertNotSame('', $result->value->trace); + + $this->assertSame(self::OK, $this->await($child)); + } + + public function testASlotSettlesExactlyOnce(): void + { + $slots = $this->slots(4); + $id = $slots->allocateSlot(); + $slots->complete($id, 1); + + $this->expectException(IpcException::class); + $this->expectExceptionMessageMatches('/already completed/'); + + $slots->complete($id, 2); + } + + public function testAPendingSlotIsReportedRatherThanGuessed(): void + { + $slots = $this->slots(4); + $id = $slots->allocateSlot(); + + $result = $slots->readSlot($id); + $this->assertTrue($result->isPending()); + $this->assertNull($result->value); + + // Awaiting one nobody will ever complete gives up on the deadline, still pending + $this->assertTrue($slots->await($id, 0.15)->isPending()); + } + + public function testAPreSizedSlotTableRefusesToGrow(): void + { + $slots = $this->slots(2); + $slots->allocateSlot(); + $slots->allocateSlot(); + + $this->expectException(IpcException::class); + $this->expectExceptionMessageMatches('/pre-sized in the arena and never grows/'); + + $slots->allocateSlot(); + } + + public function testSpawnArgumentsRideTheSameSlotsInTheOppositeDirection(): void + { + $slots = $this->slots(); + $argument = $slots->allocateSlot(); + $answer = $slots->allocateSlot(); + + // The parent hands work DOWN through a slot the child reads before starting + $slots->complete($argument, 21); + + $child = $this->fork(static function () use ($slots, $argument, $answer): int { + $input = $slots->await($argument, 5.0); + if (!$input->isDone() || !\is_int($input->value)) { + return self::WRONG_VALUE; + } + $slots->complete($answer, $input->value * 2); + + return self::OK; + }); + + $this->assertSame(42, $slots->await($answer, 10.0)->value); + $this->assertSame(self::OK, $this->await($child)); + $this->assertInstanceOf(PersistentStore::class, $this->store()); + } +} diff --git a/tests/Ipc/SerializationGuard.php b/tests/Ipc/SerializationGuard.php new file mode 100644 index 0000000..38ade24 --- /dev/null +++ b/tests/Ipc/SerializationGuard.php @@ -0,0 +1,61 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +/** + * Ledger of every encoding call the package's own namespaces made + * + * The Never-Serialize Rule is easy to state and easy to break by accident, so the test suite + * does not take the source code's word for it. PHP resolves an UNQUALIFIED function call + * against the current namespace before it falls back to the global one, which means a + * `serialize()` declared in `Lisachenko\SharedData\Ipc` intercepts every unqualified + * `serialize()` made by this package's code in that namespace - without touching the global + * function, PHPUnit or any dependency. + * + * serialization-guard.php declares those shadows for the three namespaces the data path runs + * through and routes them here. A round trip that stays at zero calls has proven that no + * value was encoded on the way; a round trip that increments anything names the culprit. + */ +final class SerializationGuard +{ + /** @var list */ + private static array $calls = []; + + private function __construct() + { + } + + public static function record(string $function): void + { + self::$calls[] = $function; + } + + public static function reset(): void + { + self::$calls = []; + } + + /** + * @return list + */ + public static function calls(): array + { + return self::$calls; + } + + public static function count(): int + { + return \count(self::$calls); + } +} diff --git a/tests/Ipc/SharedChannelForkTest.php b/tests/Ipc/SharedChannelForkTest.php new file mode 100644 index 0000000..07530f9 --- /dev/null +++ b/tests/Ipc/SharedChannelForkTest.php @@ -0,0 +1,276 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Stub\GraphNode; + +/** + * Channels between real processes: FIFO, blocking, rendezvous and close + * + * Every case forks. What separates these from a single-process exercise is that a value the + * child put into the ring cannot reach the parent through inherited pages - the child was + * running long before it sent - so the parent reading it proves the record really does live + * in the shared mapping. + */ +class SharedChannelForkTest extends IpcTestCase +{ + public function testProducerAndConsumerChildrenExchangeRecordsInFifoOrder(): void + { + $channel = $this->channel(8); + $count = 200; + + $producer = $this->fork(static function () use ($channel, $count): int { + for ($index = 0; $index < $count; $index++) { + if (!$channel->send($index, 10.0)) { + return self::TIMED_OUT; + } + } + $channel->close(); + + return self::OK; + }); + + $consumer = $this->fork(static function () use ($channel, $count): int { + for ($index = 0; $index < $count; $index++) { + [$value, $ok] = $channel->recv(10.0); + if (!$ok) { + return self::TIMED_OUT; + } + if ($value !== $index) { + // A ring is FIFO or it is nothing: an out-of-order record means two + // processes disagreed about head/tail + return self::WRONG_ORDER; + } + } + + // The producer closed after its last record: the stream ends, it does not stall + [$value, $ok] = $channel->recv(10.0); + + return $value === null && $ok === false ? self::OK : self::WRONG_STATE; + }); + + $this->awaitAll([$producer, $consumer], 'the producer/consumer pair disagreed'); + } + + public function testBlockingReceiveWakesWhenAChildSendsMuchLater(): void + { + $channel = $this->channel(4); + + $sender = $this->fork(static function () use ($channel): int { + // The parent is already parked on its notification socket by now; the value + // arrives 300 ms into its wait, and the wake event is what ends that wait + usleep(300_000); + $channel->send('late arrival', 5.0); + $channel->send(7, 5.0); + + return self::OK; + }); + + $before = microtime(true); + [$value, $ok] = $channel->recv(10.0); + $elapsed = microtime(true) - $before; + + $this->assertTrue($ok, 'the blocking receive gave up before the child sent'); + $this->assertSame('late arrival', $value); + $this->assertGreaterThan(0.2, $elapsed, 'the receive returned before the child could have sent'); + $this->assertLessThan(5.0, $elapsed, 'the receive did not wake on the event record'); + + // The second record is already buffered; taking it must not block at all + [$second, $ok] = $channel->recv(5.0); + $this->assertTrue($ok); + $this->assertSame(7, $second); + + $this->assertSame(self::OK, $this->await($sender)); + } + + public function testCapacityZeroChannelMakesTheSenderWaitForItsReceiver(): void + { + $channel = $this->channel(0); + $this->assertTrue($channel->isRendezvous()); + + $sender = $this->fork(static function () use ($channel): int { + $before = microtime(true); + if (!$channel->send('handoff', 10.0)) { + return self::TIMED_OUT; + } + + // The send is only allowed to return once the value has been TAKEN, and the + // parent deliberately takes it 400 ms late + return microtime(true) - $before > 0.2 ? self::OK : self::WRONG_STATE; + }); + + usleep(400_000); + $this->assertLessThanOrEqual(1, $channel->count(), 'a rendezvous ring never buffers more than one handoff'); + + [$value, $ok] = $channel->recv(10.0); + $this->assertTrue($ok); + $this->assertSame('handoff', $value); + + $this->assertSame( + self::OK, + $this->await($sender), + 'the rendezvous send returned before its receiver took the value', + ); + } + + public function testRendezvousTrySendOnlySucceedsWhileAReceiverIsParked(): void + { + $channel = $this->channel(0); + + // Nobody is waiting: a non-blocking handoff has nowhere to go + $this->assertFalse($channel->trySend('nobody home')); + + $receiver = $this->fork(static function () use ($channel): int { + [$value, $ok] = $channel->recv(10.0); + + return $ok && $value === 'now somebody is' ? self::OK : self::WRONG_VALUE; + }); + + $deadline = microtime(true) + 5.0; + $sent = false; + while (!$sent && microtime(true) < $deadline) { + $sent = $channel->trySend('now somebody is'); + if (!$sent) { + usleep(2_000); + } + } + + $this->assertTrue($sent, 'trySend never saw the parked receiver'); + $this->assertSame(self::OK, $this->await($receiver)); + } + + public function testCloseCrossesProcessesAndReceiversDrainWhatIsLeft(): void + { + $channel = $this->channel(8); + $channel->send(1); + $channel->send(2); + $channel->send(3); + + $closer = $this->fork(static function () use ($channel): int { + $channel->close(); + + return self::OK; + }); + $this->assertSame(self::OK, $this->await($closer)); + + // Closed by ANOTHER process, and this one sees it through the shared flag + $this->assertTrue($channel->isClosed()); + + // Buffered records survive the close and are drained in order first + foreach ([1, 2, 3] as $expected) { + [$value, $ok] = $channel->recv(5.0); + $this->assertTrue($ok); + $this->assertSame($expected, $value); + } + + [$value, $ok] = $channel->recv(5.0); + $this->assertNull($value); + $this->assertFalse($ok, 'a drained closed channel must report the end of stream'); + } + + public function testSendingIntoAChannelClosedByAnotherProcessThrows(): void + { + $channel = $this->channel(4); + + $closer = $this->fork(static function () use ($channel): int { + $channel->close(); + + return self::OK; + }); + $this->assertSame(self::OK, $this->await($closer)); + + $this->expectException(ClosedChannelException::class); + $this->expectExceptionMessageMatches('/is closed; nothing can be sent/'); + + $channel->trySend('too late'); + } + + public function testTwoProducersAndOneConsumerNeverLoseOrDuplicateARecord(): void + { + $channel = $this->channel(4); + $perChild = 100; + + $producers = []; + foreach ([1000, 2000] as $base) { + $producers[] = $this->fork(static function () use ($channel, $perChild, $base): int { + for ($index = 0; $index < $perChild; $index++) { + if (!$channel->send($base + $index, 10.0)) { + return self::TIMED_OUT; + } + } + + return self::OK; + }); + } + + $seen = []; + for ($index = 0; $index < 2 * $perChild; $index++) { + [$value, $ok] = $channel->recv(10.0); + $this->assertTrue($ok, 'the consumer starved while producers were running'); + $seen[] = $value; + } + + $this->awaitAll($producers, 'a producer failed'); + + $this->assertCount(2 * $perChild, $seen); + $this->assertCount(2 * $perChild, array_unique($seen), 'a record was delivered twice'); + $this->assertSame(0, $channel->count(), 'the ring is not empty after every record was taken'); + } + + public function testAChildSendsASharedObjectAndTheParentSeesTheSameAddress(): void + { + $channel = $this->channel(2); + $store = $this->store(); + + $shared = $this->sharedNode(); + $address = $store->addressOfInstance($shared); + $this->assertNotNull($address); + + $sender = $this->fork(static function () use ($channel, $store, $address): int { + $object = $store->attachObject($address); + // The record carries eight bytes of address; the object never moves + $channel->send($object, 5.0); + + return self::OK; + }); + + [$value, $ok] = $channel->recv(10.0); + $this->assertTrue($ok); + $this->assertInstanceOf(GraphNode::class, $value); + $this->assertSame('shared-by-address', $value->name); + $this->assertSame($address, $store->addressOfInstance($value), 'the object was copied instead of shared'); + + $this->assertSame(self::OK, $this->await($sender)); + } + + public function testTheArenaWatermarkPlateausWhileRecordsChurnThroughARing(): void + { + $channel = $this->channel(16); + + // One lap to allocate everything the exchange needs + $channel->send(1); + $channel->recv(1.0); + + $before = $this->arena()->watermark(); + for ($index = 0; $index < 5_000; $index++) { + $channel->send($index); + [$value, $ok] = $channel->recv(1.0); + $this->assertTrue($ok); + $this->assertSame($index, $value); + } + + // Scalars ride inside the record: a ring that never grows consumes nothing at all + $this->assertSame($before, $this->arena()->watermark(), 'the ring leaked arena memory per record'); + } +} diff --git a/tests/Ipc/SharedStructuresForkTest.php b/tests/Ipc/SharedStructuresForkTest.php new file mode 100644 index 0000000..ec4c69e --- /dev/null +++ b/tests/Ipc/SharedStructuresForkTest.php @@ -0,0 +1,248 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Stub\GraphNode; + +/** + * The mutable containers and the synchronization primitives, exercised by real processes + * + * The interesting cases are the ones a single process cannot fake: four children adding to + * one counter, a worker killed inside a critical section, a wait group whose units of work + * finish somewhere else entirely. + */ +class SharedStructuresForkTest extends IpcTestCase +{ + public function testFourWorkersFillOneSharedArrayAndTheParentReadsAllOfIt(): void + { + $array = SharedArray::create($this->allocator(), $this->codec(), 8); + + $children = []; + for ($index = 0; $index < 4; $index++) { + $children[] = $this->fork(static function () use ($array, $index): int { + $array[$index] = "worker-{$index}"; + $array[$index + 4] = $index * 100; + + return self::OK; + }); + } + $this->awaitAll($children); + + for ($index = 0; $index < 4; $index++) { + $this->assertSame("worker-{$index}", $array[$index], 'a worker\'s string did not reach the parent'); + $this->assertSame($index * 100, $array[$index + 4]); + } + $this->assertCount(8, $array); + } + + public function testASharedArrayCarriesEveryTagIncludingObjectsAndNestedArrays(): void + { + $inner = SharedArray::create($this->allocator(), $this->codec(), 2); + $inner[0] = 'nested'; + + $shared = $this->sharedNode(); + + $array = SharedArray::create($this->allocator(), $this->codec(), 6); + $array[0] = null; + $array[1] = true; + $array[2] = -17; + $array[3] = 0.5; + $array[4] = $shared; + $array[5] = $inner; + + $pid = $this->fork(static function () use ($array, $shared): int { + if ($array[0] !== null || $array[1] !== true || $array[2] !== -17 || $array[3] !== 0.5) { + return self::WRONG_VALUE; + } + $object = $array[4]; + if (!$object instanceof GraphNode || $object->name !== 'shared-by-address') { + return self::WRONG_VALUE; + } + // Zero-copy all the way down: the same object, at the same address + if ($object !== $shared) { + return self::WRONG_STATE; + } + $nested = $array[5]; + + return $nested instanceof SharedArray && $nested[0] === 'nested' ? self::OK : self::WRONG_VALUE; + }); + + $this->assertSame(self::OK, $this->await($pid), 'a child disagreed about the shared array'); + } + + public function testAnIndexOutsideAFixedCapacityArrayIsATypedFailure(): void + { + $array = SharedArray::create($this->allocator(), $this->codec(), 4); + + $this->assertTrue(isset($array[3])); + $this->assertFalse(isset($array[4])); + + $this->expectException(IpcException::class); + $this->expectExceptionMessageMatches('/cannot grow/'); + + $array[4] = 'past the end'; + } + + public function testAppendingToASharedArrayIsRefusedBecauseItCannotGrow(): void + { + $array = SharedArray::create($this->allocator(), $this->codec(), 2); + + $this->expectException(IpcException::class); + + $array[] = 'append'; + } + + public function testASharedMutexExcludesTwoProcessesFromOneCriticalSection(): void + { + $arena = $this->arena(); + $mutex = SharedMutex::create($arena); + $witness = $arena->allocate(8); + $arena->writeWord($witness, 0); + + $holder = $this->fork(static function () use ($arena, $mutex, $witness): int { + if (!$mutex->lock(5.0)) { + return self::TIMED_OUT; + } + // Inside the critical section: announce it, stay a while, then leave + $arena->writeWord($witness, 1); + usleep(300_000); + $arena->writeWord($witness, 0); + $mutex->unlock(); + + return self::OK; + }); + + $this->assertTrue($this->awaitWord($witness, 1), 'the child never entered the critical section'); + $this->assertFalse($mutex->tryLock(), 'two processes were allowed into one critical section'); + + $before = microtime(true); + $this->assertTrue($mutex->lock(10.0), 'the parent never got the lock after the child released it'); + $this->assertSame(0, $arena->readWord($witness), 'the lock was granted while the child was still inside'); + $mutex->unlock(); + + $this->assertGreaterThan(0.05, microtime(true) - $before, 'the parent did not actually wait for the child'); + $this->assertSame(self::OK, $this->await($holder)); + } + + public function testAMutexHeldByAKilledWorkerIsRecoveredRatherThanLostForever(): void + { + $arena = $this->arena(); + $mutex = SharedMutex::create($arena); + $signal = $arena->allocate(8); + $arena->writeWord($signal, 0); + + $victim = $this->fork(static function () use ($arena, $mutex, $signal): int { + $mutex->lock(5.0); + // Announce the lock is held, then die inside the critical section + $arena->writeWord($signal, 1); + sleep(30); + + return self::OK; + }); + + $this->assertTrue($this->awaitWord($signal, 1), 'the victim never took the lock'); + posix_kill($victim, SIGKILL); + pcntl_waitpid($victim, $status); + + // ROBUST: the next locker is told the owner died (EOWNERDEAD) and makes it consistent + // again, instead of blocking on a lock nobody will ever release + $this->assertTrue($mutex->lock(10.0)); + $this->assertTrue($mutex->wasRecovered(), 'the died owner was not reported through the lock result'); + $mutex->unlock(); + + // ... and the mutex is an ordinary working mutex afterwards + $this->assertTrue($mutex->tryLock()); + $mutex->unlock(); + } + + public function testFourChildrenAddingToOneAtomicIntSumCorrectly(): void + { + $counter = AtomicInt::create($this->arena(), 0); + $perChild = 250; + $childCount = 4; + + $children = []; + for ($index = 0; $index < $childCount; $index++) { + $children[] = $this->fork(static function () use ($counter, $perChild): int { + for ($step = 0; $step < $perChild; $step++) { + $counter->add(1); + } + + return self::OK; + }); + } + $this->awaitAll($children); + + // A lost update would show up here as a number below the sum; the stripe mutex is + // what makes read-modify-write safe without a CAS instruction + $this->assertSame($childCount * $perChild, $counter->get()); + } + + public function testCompareAndSetLetsExactlyOneChildClaimAToken(): void + { + $token = AtomicInt::create($this->arena(), 0); + $claims = AtomicInt::create($this->arena(), 0); + + $children = []; + for ($index = 1; $index <= 4; $index++) { + $children[] = $this->fork(static function () use ($token, $claims, $index): int { + if ($token->compareAndSet(0, $index)) { + $claims->add(1); + } + + return self::OK; + }); + } + $this->awaitAll($children); + + $this->assertSame(1, $claims->get(), 'more than one child won the same compare-and-set'); + $this->assertGreaterThan(0, $token->get()); + } + + public function testAWaitGroupParksTheParentUntilEveryChildIsDone(): void + { + $group = SharedWaitGroup::create($this->arena(), $this->wake()); + $group->add(4); + + $children = []; + for ($index = 0; $index < 4; $index++) { + $children[] = $this->fork(static function () use ($group, $index): int { + usleep(50_000 * ($index + 1)); + $group->done(); + + return self::OK; + }); + } + + $before = microtime(true); + $this->assertTrue($group->wait(10.0), 'the wait group never reached zero'); + $elapsed = microtime(true) - $before; + + $this->assertSame(0, $group->count()); + $this->assertGreaterThan(0.15, $elapsed, 'the wait returned before the slowest child was done'); + $this->awaitAll($children); + } + + public function testAWaitGroupCounterGoingNegativeIsAHardError(): void + { + $group = SharedWaitGroup::create($this->arena(), $this->wake()); + $group->add(1); + $group->done(); + + $this->expectException(IpcException::class); + $this->expectExceptionMessageMatches('/went negative/'); + + $group->done(); + } +} diff --git a/tests/Ipc/ValueCodecTest.php b/tests/Ipc/ValueCodecTest.php new file mode 100644 index 0000000..e5e1daf --- /dev/null +++ b/tests/Ipc/ValueCodecTest.php @@ -0,0 +1,156 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Ipc; + +use Lisachenko\SharedData\Stub\GraphNode; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * The tag contract: what a value record may carry, and what it must refuse + * + * The refusals matter as much as the round trips. Every value that cannot travel by address + * has exactly one honest answer - a typed exception naming the remedy - because the only + * alternative would be to encode it into bytes, which is the one thing this package does not + * do at any price. + */ +class ValueCodecTest extends IpcTestCase +{ + /** + * @return iterable + */ + public static function shareableValues(): iterable + { + yield 'null' => [null, ValueTag::Nil]; + yield 'true' => [true, ValueTag::True]; + yield 'false' => [false, ValueTag::False]; + yield 'int' => [42, ValueTag::Int]; + yield 'negative int' => [-1_000_000, ValueTag::Int]; + yield 'max int' => [PHP_INT_MAX, ValueTag::Int]; + yield 'float' => [3.141592653589793, ValueTag::Float]; + yield 'negative zero' => [-0.0, ValueTag::Float]; + yield 'string' => ['a string that lives in the arena', ValueTag::Str]; + yield 'empty string' => ['', ValueTag::Str]; + yield 'binary string' => ["\x00\x01\xfe\xff", ValueTag::Str]; + } + + #[DataProvider('shareableValues')] + public function testValuesSurviveTheirRecordUnchanged(mixed $value, ValueTag $expected): void + { + [$tag, $payload] = $this->codec()->encode($value); + + $this->assertSame($expected, $tag); + $this->assertSame($value, $this->codec()->decode($tag, $payload)); + } + + public function testAStringRecordCarriesAnArenaAddressRatherThanBytes(): void + { + [$tag, $payload] = $this->codec()->encode('interned into the arena'); + + $this->assertSame(ValueTag::Str, $tag); + $this->assertTrue($tag->isAddress()); + $this->assertTrue($this->arena()->contains($payload, 8), 'the string was not interned into the arena'); + } + + public function testASharedObjectRecordCarriesItsAddress(): void + { + $shared = $this->sharedNode(); + + [$tag, $payload] = $this->codec()->encode($shared); + + $this->assertSame(ValueTag::Obj, $tag); + $this->assertSame($this->store()->addressOfInstance($shared), $payload); + $this->assertSame($shared, $this->codec()->decode($tag, $payload)); + } + + public function testASharedArrayRecordCarriesItsAddress(): void + { + $array = SharedArray::create($this->allocator(), $this->codec(), 4); + $array[0] = 'inside a shared array'; + + [$tag, $payload] = $this->codec()->encode($array); + $this->assertSame(ValueTag::Arr, $tag); + $this->assertSame($array->address(), $payload); + + $decoded = $this->codec()->decode($tag, $payload); + $this->assertInstanceOf(SharedArray::class, $decoded); + $this->assertSame($array->address(), $decoded->address()); + $this->assertSame('inside a shared array', $decoded[0]); + } + + public function testAPlainArrayIsRefusedAndPointsAtSharedArray(): void + { + $this->expectException(NotShareableValueException::class); + $this->expectExceptionMessageMatches('/SharedArray/'); + + $this->codec()->encode([1, 2, 3]); + } + + public function testAClosureIsRefusedOnProvenanceAndPointsAtTheTaskTicket(): void + { + $this->expectException(NotShareableValueException::class); + // Rejected because provenance cannot be recovered from the object, not because of + // anything the closure looks like (EPIC #15, correction #8) + $this->expectExceptionMessageMatches('/compiled BEFORE the fork barrier.+Task object/s'); + + $this->codec()->encode(static fn (): int => 1); + } + + public function testAResourceIsRefused(): void + { + $handle = fopen('php://memory', 'rb'); + $this->assertNotFalse($handle); + + try { + $this->expectException(NotShareableValueException::class); + $this->expectExceptionMessageMatches('/per-process table/'); + + $this->codec()->encode($handle); + } finally { + fclose($handle); + } + } + + public function testAnOrdinaryObjectIsRefusedAndNamesPersist(): void + { + $this->expectException(NotShareableValueException::class); + $this->expectExceptionMessageMatches('/PersistentStore::persist\(/'); + + $this->codec()->encode(new GraphNode()); + } + + public function testAnAddressOutsideTheArenaIsNeverDereferenced(): void + { + $this->expectException(NotShareableValueException::class); + $this->expectExceptionMessageMatches('/not inside this arena/'); + + // A record whose payload points anywhere else is refused before anything reads it + $this->codec()->decode(ValueTag::Str, 0x1000); + } + + public function testEveryRecordIsSixteenBytesOfTagAndPayload(): void + { + $this->assertSame(16, ValueRecord::SIZE); + $this->assertSame(2, ValueRecord::WORDS); + + $address = $this->arena()->allocate(ValueRecord::SIZE); + ValueRecord::write($this->arena(), $address, ValueTag::Int, -7); + + $this->assertSame(ValueTag::Int, ValueRecord::readTag($this->arena(), $address)); + $this->assertSame(-7, ValueRecord::readPayload($this->arena(), $address)); + + // The seven padding bytes stay zero, which is what keeps the tag word readable as + // the bare tag in every process + $this->assertSame(ValueTag::Int->value, $this->arena()->readWord($address)); + } +} diff --git a/tests/Ipc/serialization-guard.php b/tests/Ipc/serialization-guard.php new file mode 100644 index 0000000..f825ca9 --- /dev/null +++ b/tests/Ipc/serialization-guard.php @@ -0,0 +1,154 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + * Namespace-local shadows of every encoding function, for the three namespaces this + * package's data path runs through. An unqualified call from package code lands here, + * is recorded in SerializationGuard and then forwarded to the real global function - + * so the guard observes without changing behaviour. Loaded explicitly by the test that + * asserts on it (bracketed namespaces cannot be autoloaded). + */ + +namespace Lisachenko\SharedData\Ipc { + function serialize(mixed $value): string + { + SerializationGuard::record(__FUNCTION__); + + return \serialize($value); + } + + function unserialize(string $data, array $options = []): mixed + { + SerializationGuard::record(__FUNCTION__); + + return \unserialize($data, $options); + } + + function json_encode(mixed $value, int $flags = 0, int $depth = 512): string|false + { + SerializationGuard::record(__FUNCTION__); + + return \json_encode($value, $flags, $depth); + } + + function json_decode(string $json, ?bool $associative = null, int $depth = 512, int $flags = 0): mixed + { + SerializationGuard::record(__FUNCTION__); + + return \json_decode($json, $associative, $depth, $flags); + } + + function igbinary_serialize(mixed $value): ?string + { + SerializationGuard::record(__FUNCTION__); + + return \function_exists('\igbinary_serialize') ? \igbinary_serialize($value) : null; + } + + function igbinary_unserialize(string $data): mixed + { + SerializationGuard::record(__FUNCTION__); + + return \function_exists('\igbinary_unserialize') ? \igbinary_unserialize($data) : null; + } + + function var_export(mixed $value, bool $return = false): ?string + { + SerializationGuard::record(__FUNCTION__); + + return \var_export($value, $return); + } +} + +namespace Lisachenko\SharedData { + use Lisachenko\SharedData\Ipc\SerializationGuard; + + function serialize(mixed $value): string + { + SerializationGuard::record(__FUNCTION__); + + return \serialize($value); + } + + function unserialize(string $data, array $options = []): mixed + { + SerializationGuard::record(__FUNCTION__); + + return \unserialize($data, $options); + } + + function json_encode(mixed $value, int $flags = 0, int $depth = 512): string|false + { + SerializationGuard::record(__FUNCTION__); + + return \json_encode($value, $flags, $depth); + } + + function json_decode(string $json, ?bool $associative = null, int $depth = 512, int $flags = 0): mixed + { + SerializationGuard::record(__FUNCTION__); + + return \json_decode($json, $associative, $depth, $flags); + } + + function igbinary_serialize(mixed $value): ?string + { + SerializationGuard::record(__FUNCTION__); + + return \function_exists('\igbinary_serialize') ? \igbinary_serialize($value) : null; + } + + function var_export(mixed $value, bool $return = false): ?string + { + SerializationGuard::record(__FUNCTION__); + + return \var_export($value, $return); + } +} + +namespace Lisachenko\SharedData\Shm { + use Lisachenko\SharedData\Ipc\SerializationGuard; + + function serialize(mixed $value): string + { + SerializationGuard::record(__FUNCTION__); + + return \serialize($value); + } + + function unserialize(string $data, array $options = []): mixed + { + SerializationGuard::record(__FUNCTION__); + + return \unserialize($data, $options); + } + + function json_encode(mixed $value, int $flags = 0, int $depth = 512): string|false + { + SerializationGuard::record(__FUNCTION__); + + return \json_encode($value, $flags, $depth); + } + + function igbinary_serialize(mixed $value): ?string + { + SerializationGuard::record(__FUNCTION__); + + return \function_exists('\igbinary_serialize') ? \igbinary_serialize($value) : null; + } + + function var_export(mixed $value, bool $return = false): ?string + { + SerializationGuard::record(__FUNCTION__); + + return \var_export($value, $return); + } +} diff --git a/tests/Shm/ArenaForkTest.php b/tests/Shm/ArenaForkTest.php new file mode 100644 index 0000000..ba68831 --- /dev/null +++ b/tests/Shm/ArenaForkTest.php @@ -0,0 +1,327 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use PHPUnit\Framework\TestCase; + +/** + * The arena's reason to exist: real processes, one region of memory, no serialization + * + * Every case here forks with pcntl. A child answers by EXIT CODE (0 = the expectation + * held, a per-case non-zero code says which expectation did not), and where a value has + * to travel between processes it travels as raw bytes over a socket pair - an address is + * eight bytes of `pack('P')`, never a serialized PHP value. That is the Never-Serialize + * Rule the whole epic is built on, applied to its own test suite. + * + * Children terminate with an immediate `exit()` inside the forked copy of the PHPUnit + * process; they never touch the result printer, and the parent is the only process that + * asserts. + */ +class ArenaForkTest extends TestCase +{ + private const int TEST_SIZE = 4 << 20; + + /** + * Exit codes children answer with, so a failure names the expectation that broke + */ + private const int OK = 0; + private const int WRONG_WORD = 11; + private const int WRONG_BYTES = 12; + private const int WRONG_BASE = 13; + private const int CHILD_EXCEPTION = 40; + + protected function setUp(): void + { + if (!\function_exists('pcntl_fork')) { + $this->markTestSkipped('ext-pcntl is required to exercise fork-shared memory'); + } + } + + public function testChildrenReadValuesPersistedBeforeTheFork(): void + { + $arena = Arena::create(self::TEST_SIZE); + $address = $arena->allocate(64); + $arena->writeWord($address, 0xC0FFEE); + $arena->writeBytes($address + 8, 'shared before the fork'); + + $base = $arena->baseAddress(); + + // Two children, both reading the very same address the parent wrote to + $children = []; + for ($index = 0; $index < 2; $index++) { + $children[] = $this->fork(static function () use ($arena, $address, $base): int { + if ($arena->baseAddress() !== $base) { + return self::WRONG_BASE; + } + if ($arena->readWord($address) !== 0xC0FFEE) { + return self::WRONG_WORD; + } + + return $arena->readBytes($address + 8, 22) === 'shared before the fork' + ? self::OK + : self::WRONG_BYTES; + }); + } + + foreach ($children as $pid) { + $this->assertSame(self::OK, $this->await($pid), 'a child disagreed about the pre-fork value'); + } + } + + public function testValueAllocatedByAChildIsReachableByTheParentAndASibling(): void + { + $arena = Arena::create(self::TEST_SIZE); + [$parentEnd, $childEnd] = $this->socketPair(); + + $writer = $this->fork(static function () use ($arena, $childEnd): int { + $address = $arena->allocate(64); + $arena->writeWord($address, 0x5EED); + $arena->writeBytes($address + 8, 'written after the fork'); + + // The ONLY thing that crosses the process boundary: eight bytes of address + socket_write($childEnd, pack('P', $address), 8); + + return self::OK; + }); + socket_close($childEnd); + + $payload = (string) socket_read($parentEnd, 8, PHP_BINARY_READ); + socket_close($parentEnd); + $this->assertSame(8, \strlen($payload), 'the child did not report its allocation'); + + /** @var array{1: int} $unpacked */ + $unpacked = unpack('P', $payload); + $address = $unpacked[1]; + + $this->assertSame(self::OK, $this->await($writer)); + + // The parent reads what a different process allocated and wrote + $this->assertSame(0x5EED, $arena->readWord($address)); + $this->assertSame('written after the fork', $arena->readBytes($address + 8, 22)); + + // ... and so does a sibling forked afterwards, from the same eight bytes + $sibling = $this->fork(static function () use ($arena, $address): int { + if ($arena->readWord($address) !== 0x5EED) { + return self::WRONG_WORD; + } + + return $arena->readBytes($address + 8, 22) === 'written after the fork' + ? self::OK + : self::WRONG_BYTES; + }); + + $this->assertSame(self::OK, $this->await($sibling), 'a sibling could not attach the address'); + } + + public function testConcurrentAllocationFromFourChildrenNeverOverlaps(): void + { + $arena = Arena::create(self::TEST_SIZE); + $blocksPerChild = 250; + + /** @var list $children */ + $children = []; + for ($index = 0; $index < 4; $index++) { + [$parentEnd, $childEnd] = $this->socketPair(); + + $marker = 0x41 + $index; + $pid = $this->fork(static function () use ($arena, $childEnd, $marker, $blocksPerChild): int { + $records = ''; + for ($block = 0; $block < $blocksPerChild; $block++) { + $size = 8 + ($block % 41); + $address = $arena->allocate($size, 8); + $arena->writeBytes($address, str_repeat(\chr($marker), $size)); + $records .= pack('PP', $address, $size); + } + socket_write($childEnd, $records); + + return self::OK; + }); + socket_close($childEnd); + + $children[] = [$pid, $parentEnd, $marker]; + } + + /** @var list $blocks */ + $blocks = []; + foreach ($children as [$pid, $socket, $marker]) { + $payload = ''; + while (($chunk = socket_read($socket, 65536, PHP_BINARY_READ)) !== false && $chunk !== '') { + $payload .= $chunk; + } + socket_close($socket); + $this->assertSame(self::OK, $this->await($pid)); + + for ($offset = 0; $offset < \strlen($payload); $offset += 16) { + /** @var array{1: int, 2: int} $record */ + $record = unpack('Paddress/Psize', substr($payload, $offset, 16)); + $blocks[] = [$record['address'], $record['size'], $marker]; + } + } + + $this->assertCount(4 * $blocksPerChild, $blocks); + + usort($blocks, static fn (array $left, array $right): int => $left[0] <=> $right[0]); + + $previousEnd = 0; + foreach ($blocks as [$address, $size, $marker]) { + $this->assertGreaterThanOrEqual($previousEnd, $address, 'two children were handed overlapping blocks'); + $previousEnd = $address + $size; + + // Every byte still carries the marker of the child that owns the block + $this->assertSame(str_repeat(\chr($marker), $size), $arena->readBytes($address, $size)); + } + } + + public function testWatermarkSeenByTheParentIncludesWhatChildrenAllocated(): void + { + $arena = Arena::create(self::TEST_SIZE); + $before = $arena->watermark(); + + $children = []; + for ($index = 0; $index < 2; $index++) { + $children[] = $this->fork(static function () use ($arena): int { + $arena->allocate(4096, 8); + + return self::OK; + }); + } + foreach ($children as $pid) { + $this->assertSame(self::OK, $this->await($pid)); + } + + // The cursor lives in the arena, not in a process: both children moved THIS one + $this->assertGreaterThanOrEqual($before + 2 * 4096, $arena->watermark()); + } + + public function testNamedRootPublishedByAChildIsFoundByTheParent(): void + { + $arena = Arena::create(self::TEST_SIZE); + + $pid = $this->fork(static function () use ($arena): int { + $address = $arena->allocate(32); + $arena->writeWord($address, 0xBEEF); + $arena->putRoot('child.published', $address); + + return self::OK; + }); + $this->assertSame(self::OK, $this->await($pid)); + + $address = $arena->findRoot('child.published'); + $this->assertNotNull($address); + $this->assertSame(0xBEEF, $arena->readWord($address)); + } + + public function testOnlyTheCreatingProcessUnmapsTheArena(): void + { + $arena = Arena::create(self::TEST_SIZE); + $address = $arena->allocate(16); + $arena->writeWord($address, 0x1234); + + $pid = $this->fork(static function () use ($arena): int { + if ($arena->isCreator()) { + return self::WRONG_BASE; + } + // A child unmapping the region would tear it out from under the family: no-op + $arena->destroy(); + + return $arena->readWord(0) === 0 ? self::WRONG_WORD : self::OK; + }); + + // The child's readWord(0) is out of bounds and throws, which is the CHILD_EXCEPTION + // path - what matters is that the parent's arena is untouched afterwards + $this->assertContains($this->await($pid), [self::OK, self::CHILD_EXCEPTION]); + $this->assertSame(0x1234, $arena->readWord($address)); + $this->assertTrue($arena->isCreator()); + } + + public function testStripeLockIsRecoveredWhenItsOwnerIsKilled(): void + { + $arena = Arena::create(self::TEST_SIZE); + $signal = $arena->allocate(8); + $arena->writeWord($signal, 0); + + $victim = $this->fork(static function () use ($arena, $signal): int { + $arena->lockStripe(Arena::FIRST_STRIPE); + $arena->writeWord($signal, 1); + sleep(30); + + return self::OK; + }); + + while ($arena->readWord($signal) !== 1) { + usleep(1_000); + } + posix_kill($victim, SIGKILL); + pcntl_waitpid($victim, $status); + + // ROBUST: the next locker is told the owner died instead of blocking forever + $this->assertTrue($arena->lockStripe(Arena::FIRST_STRIPE)); + $arena->unlockStripe(Arena::FIRST_STRIPE); + + // ... and the mutex is an ordinary working mutex again + $this->assertFalse($arena->lockStripe(Arena::FIRST_STRIPE)); + $arena->unlockStripe(Arena::FIRST_STRIPE); + } + + /** + * Runs $body in a forked child and returns its pid + * + * The child never returns into PHPUnit: it exits with the code $body produced, so the + * only thing the parent has to interpret is an integer. + * + * @param callable(): int $body + */ + private function fork(callable $body): int + { + $pid = pcntl_fork(); + $this->assertNotSame(-1, $pid, 'pcntl_fork() failed'); + + if ($pid > 0) { + return $pid; + } + + $code = self::CHILD_EXCEPTION; + + try { + $code = $body(); + } catch (\Throwable) { + // Reported as CHILD_EXCEPTION: a child must never print into the parent's run + } + + exit($code); + } + + /** + * Waits for one child and returns its exit code + */ + private function await(int $pid): int + { + $status = 0; + pcntl_waitpid($pid, $status); + $this->assertTrue(pcntl_wifexited($status), "child {$pid} did not exit normally"); + + return pcntl_wexitstatus($status); + } + + /** + * @return array{0: resource, 1: resource} + */ + private function socketPair(): array + { + $pair = []; + $this->assertTrue(socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $pair), 'cannot create a socket pair'); + + return [$pair[0], $pair[1]]; + } +} diff --git a/tests/Shm/ArenaModuleInfoTest.php b/tests/Shm/ArenaModuleInfoTest.php new file mode 100644 index 0000000..4e0687a --- /dev/null +++ b/tests/Shm/ArenaModuleInfoTest.php @@ -0,0 +1,59 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use Lisachenko\SharedData\PersistentStore; +use Lisachenko\SharedData\Stub\AppConfig; +use PHPUnit\Framework\TestCase; + +/** + * Reporting on an arena-backed module must never interpret its anchor as a registry + * + * A module's globals[0] means two different things: a registry hashtable in the default + * mode, the ARENA BASE in arena mode. phpinfo() walks EVERY registered module, so a worker + * running both modes had one path where the arena header was read as a hashtable - a + * segfault, not an exception. The state is reported through the live store instead, and the + * arena magic is the fallback discriminator when there is no store to ask. + */ +class ArenaModuleInfoTest extends TestCase +{ + private const string MODULE = 'shared_arena_info'; + + private static ?PersistentStore $store = null; + + private function store(): PersistentStore + { + return self::$store ??= PersistentStore::bootShared(Arena::create(4 << 20), null, self::MODULE); + } + + public function testPhpinfoReportsAnArenaBackedModuleInsteadOfDereferencingItsAnchor(): void + { + $config = new AppConfig(); + $config->env = 'arena-info'; + $config->bootCount = 1; + $config->label = 'primary'; + $config->settings = []; + + $this->store()->persist(AppConfig::class, $config); + + ob_start(); + phpinfo(INFO_MODULES); + $info = (string) ob_get_clean(); + + $section = strstr((string) strstr($info, self::MODULE), "\n\n", true); + $this->assertIsString($section); + $this->assertStringContainsString('Persistent objects support => enabled', $section); + $this->assertStringContainsString(AppConfig::class, $section, 'the arena registry was not reported'); + } +} diff --git a/tests/Shm/ArenaRegistryTest.php b/tests/Shm/ArenaRegistryTest.php new file mode 100644 index 0000000..76809c9 --- /dev/null +++ b/tests/Shm/ArenaRegistryTest.php @@ -0,0 +1,178 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use Lisachenko\SharedData\PersistedEntry; +use Lisachenko\SharedData\Registry; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; + +/** + * The registry-in-arena: pre-sized tables the engine is never allowed to grow + * + * A registry table that grows is not a performance problem, it is silent corruption: the + * engine reallocates the bucket block into the private heap of whichever process filled + * the table, writes that address into the SHARED struct and carries on, so every sibling + * keeps reading a pointer into memory it does not own. The guard therefore has to fire on + * the insert, before anything moves - and the recovery path has to be able to notice a + * block that has left the arena. + */ +class ArenaRegistryTest extends TestCase +{ + private const int ARENA_SIZE = 4 << 20; + + /** + * @return array{0: Arena, 1: ArenaAllocator} + */ + private function makeArena(): array + { + $arena = Arena::create(self::ARENA_SIZE); + + return [$arena, new ArenaAllocator($arena)]; + } + + public function testLayoutVersionIsFive(): void + { + // The version the module globals are checked against: v4 added arena tables, v5 the + // per-object role, without which a worker would apply frozen semantics to a mutable + // graph its siblings are writing + $this->assertSame(5, Registry::LAYOUT_VERSION); + } + + public function testTablesArePublishedInTheArenaRootsDirectory(): void + { + [$arena, $allocator] = $this->makeArena(); + + [$registry, $base] = Registry::createInArena($allocator); + + $this->assertTrue($registry->isArenaBacked()); + $this->assertSame($arena->baseAddress(), $base, 'module globals must anchor the arena, not the registry'); + + foreach ([ + ArenaRegistryLayout::ROOT_TABLE, + ArenaRegistryLayout::ROOT_ENTRIES, + ArenaRegistryLayout::ROOT_OBJECTS, + ] as $name) { + $address = $arena->findRoot($name); + $this->assertNotNull($address, "{$name} was not published"); + $this->assertTrue($arena->contains($address, 8), "{$name} is not arena memory"); + } + } + + public function testRegistryIsRecoveredFromTheArenaAlone(): void + { + [, $allocator] = $this->makeArena(); + + [$registry] = Registry::createInArena($allocator); + $registry->store('Graph\\First', new PersistedEntry([], [])); + $registry->store('Graph\\Second', new PersistedEntry([], [])); + + // Everything a forked child has: the mapping, and the names in its roots directory + $recovered = Registry::fromArena($allocator); + + $this->assertTrue($recovered->isArenaBacked()); + $this->assertTrue($recovered->has('Graph\\First')); + $this->assertTrue($recovered->has('Graph\\Second')); + $this->assertSame(['Graph\\First', 'Graph\\Second'], $recovered->names()); + } + + public function testEntriesTableRefusesToGrowAndSaysWhichTableFilledUp(): void + { + [, $allocator] = $this->makeArena(); + + [$registry] = Registry::createInArena($allocator, new ArenaRegistryLayout(8, 8)); + + for ($index = 0; $index < 8; $index++) { + $registry->store("Graph\\Number{$index}", new PersistedEntry([], [])); + } + $this->assertCount(8, $registry->names()); + + $this->expectException(ArenaException::class); + $this->expectExceptionMessageMatches('/registry table "entries" is full/'); + + $registry->store('Graph\\OneTooMany', new PersistedEntry([], [])); + } + + public function testRefusedInsertLeavesTheTableWhereItWas(): void + { + [$arena, $allocator] = $this->makeArena(); + + [$registry] = Registry::createInArena($allocator, new ArenaRegistryLayout(8, 8)); + for ($index = 0; $index < 8; $index++) { + $registry->store("Graph\\Number{$index}", new PersistedEntry([], [])); + } + + $entriesAddress = $arena->requireRoot(ArenaRegistryLayout::ROOT_ENTRIES); + $before = $this->dataBlockAddress($entriesAddress); + + try { + $registry->store('Graph\\OneTooMany', new PersistedEntry([], [])); + } catch (ArenaException) { + // expected + } + + // The one observable symptom of a resize is a changed data-block address; the + // refusal must leave it exactly where it was, inside the arena + $this->assertSame($before, $this->dataBlockAddress($entriesAddress)); + $this->assertTrue($arena->contains($before, 8)); + $this->assertCount(8, $registry->names()); + + // ... and recovery still accepts the registry + $this->assertCount(8, Registry::fromArena($allocator)->names()); + } + + public function testUpsertOfAnExistingKeyIsAllowedOnAFullTable(): void + { + [, $allocator] = $this->makeArena(); + + [$registry] = Registry::createInArena($allocator, new ArenaRegistryLayout(8, 8)); + for ($index = 0; $index < 8; $index++) { + $registry->store("Graph\\Number{$index}", new PersistedEntry([], [])); + } + + // Replacing a graph consumes no bucket slot, so a full table must still accept it - + // otherwise a worker could never re-persist anything once the registry filled up + $registry->store('Graph\\Number3', new PersistedEntry([], [])); + + $this->assertCount(8, $registry->names()); + } + + public function testHeapRegistryIsUnaffectedByAnyOfThis(): void + { + [$registry] = Registry::create(); + + $this->assertFalse($registry->isArenaBacked()); + + // No capacity anywhere in sight: heap tables grow exactly as they did in v3 + for ($index = 0; $index < 64; $index++) { + $registry->store("Graph\\Heap{$index}", new PersistedEntry([], [])); + } + $this->assertCount(64, $registry->names()); + } + + /** + * Address of a table's bucket block, the way the engine's HT_GET_DATA_ADDR computes it + */ + private function dataBlockAddress(int $tableAddress): int + { + $raw = Core::pointerAtAddress('HashTable *', $tableAddress); + + $mask = $raw->nTableMask; + if ($mask > 0x7FFFFFFF) { + $mask -= 0x100000000; + } + + return Core::addressOf($raw->arData) + $mask * 4; + } +} diff --git a/tests/Shm/ArenaStoreForkTest.php b/tests/Shm/ArenaStoreForkTest.php new file mode 100644 index 0000000..a5deb0b --- /dev/null +++ b/tests/Shm/ArenaStoreForkTest.php @@ -0,0 +1,320 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use Lisachenko\SharedData\PersistentStore; +use Lisachenko\SharedData\Stub\AppConfig; +use Lisachenko\SharedData\Stub\GraphNode; +use PHPUnit\Framework\TestCase; + +/** + * Arena-backed persistence, across real processes + * + * What separates these cases from the frozen-mode suites is that nothing here can be + * explained by copy-on-write inheritance: a graph persisted by a CHILD after the fork + * cannot reach its parent through inherited pages, so the parent reading it proves the + * bytes really do live in one shared mapping. Addresses cross process boundaries as eight + * raw bytes over a socket - the Never-Serialize Rule applied to the test suite itself. + * + * One arena and one module per process: the arena must exist before any fork, and module + * globals[0] anchors exactly one arena for the lifetime of the worker (a second one is + * refused, deliberately). Guard cases that need their own capacity limits therefore boot + * their own module. + */ +class ArenaStoreForkTest extends TestCase +{ + private const int ARENA_SIZE = 32 << 20; + + private const int OK = 0; + private const int WRONG_SCALAR = 11; + private const int WRONG_STRING = 12; + private const int WRONG_ARRAY = 13; + private const int NOT_IN_ARENA = 14; + private const int CHILD_EXCEPTION = 40; + + private static ?Arena $arena = null; + + private static ?PersistentStore $store = null; + + protected function setUp(): void + { + if (!\function_exists('pcntl_fork')) { + $this->markTestSkipped('ext-pcntl is required to exercise fork-shared memory'); + } + + // Every class whose objects travel through the arena must be loaded BEFORE the + // fork: a shared clone carries ONE zend_class_entry pointer for the whole family, + // so the address has to mean the same thing in every process (in production that + // is what opcache.preload is for). A class first autoloaded inside a child lands + // at an address only that child can follow. + $this->assertTrue(class_exists(AppConfig::class)); + $this->assertTrue(class_exists(GraphNode::class)); + } + + protected function tearDown(): void + { + self::$store?->detach(); + } + + /** + * The one arena of this process, with the store anchored in it + */ + private function store(): PersistentStore + { + self::$arena ??= Arena::create(self::ARENA_SIZE); + self::$store ??= PersistentStore::bootShared(self::$arena); + + return self::$store; + } + + private function arena(): Arena + { + $this->store(); + \assert(self::$arena !== null); + + return self::$arena; + } + + private function makeConfig(string $env, int $bootCount): AppConfig + { + $config = new AppConfig(); + $config->env = $env; + $config->bootCount = $bootCount; + $config->label = 'primary'; + $config->settings = [ + 'db' => ['host' => 'localhost', 'port' => 5432], + 'features' => ['alpha', 'beta'], + ]; + + return $config; + } + + public function testPersistedGraphLivesInsideTheArena(): void + { + $store = $this->store(); + $arena = $this->arena(); + + $before = $arena->watermark(); + $store->persist(AppConfig::class, $this->makeConfig('production', 1)); + $address = $store->addressOf(AppConfig::class); + + $this->assertNotNull($address); + $this->assertTrue($arena->contains($address, 64), 'the persisted clone is not arena memory'); + $this->assertGreaterThan($before, $arena->watermark(), 'persisting did not consume arena bytes'); + } + + public function testTwoChildrenReadTheGraphPersistedBeforeTheFork(): void + { + $store = $this->store(); + $arena = $this->arena(); + + $store->persist(AppConfig::class, $this->makeConfig('production', 7)); + $address = $store->addressOf(AppConfig::class); + $this->assertNotNull($address); + + $children = []; + for ($index = 0; $index < 2; $index++) { + $children[] = $this->fork(static function () use ($arena, $address): int { + $childStore = PersistentStore::bootShared($arena); + $config = $childStore->get(AppConfig::class); + + if (!$config instanceof AppConfig || $config->bootCount !== 7) { + return self::WRONG_SCALAR; + } + if ($config->env !== 'production' || $config->label !== 'primary') { + return self::WRONG_STRING; + } + if ($config->settings['db']['port'] !== 5432 || $config->settings['features'][1] !== 'beta') { + return self::WRONG_ARRAY; + } + + // ... and it is the very same object, at the very same address + return $childStore->addressOf(AppConfig::class) === $address ? self::OK : self::NOT_IN_ARENA; + }); + } + + foreach ($children as $pid) { + $this->assertSame(self::OK, $this->await($pid), 'a child disagreed about the shared graph'); + } + } + + public function testGraphPersistedByAChildIsAttachedByTheParentAndASiblingThroughItsAddress(): void + { + $store = $this->store(); + $arena = $this->arena(); + + // Make sure the parent has an attached state BEFORE the child persists, so the + // object the child creates is genuinely new to it + $store->persist(AppConfig::class, $this->makeConfig('production', 1)); + + [$parentEnd, $childEnd] = $this->socketPair(); + + $writer = $this->fork(static function () use ($arena, $childEnd): int { + $childStore = PersistentStore::bootShared($arena); + + $node = new GraphNode(); + $node->name = 'minted-after-the-fork'; + $node->counter = 42; + + $childStore->persist(GraphNode::class, $node); + $address = $childStore->addressOf(GraphNode::class); + \assert($address !== null); + + // The only thing that crosses: eight bytes of address + socket_write($childEnd, pack('P', $address), 8); + + return self::OK; + }); + socket_close($childEnd); + + $payload = (string) socket_read($parentEnd, 8, PHP_BINARY_READ); + socket_close($parentEnd); + $this->assertSame(self::OK, $this->await($writer)); + $this->assertSame(8, \strlen($payload), 'the child did not report the address of its graph'); + + /** @var array{1: int} $unpacked */ + $unpacked = unpack('P', $payload); + $address = $unpacked[1]; + + // Copy-on-write cannot explain this: the object was created after the fork + $this->assertTrue($arena->contains($address, 64)); + + $attached = $store->attachObject($address); + $this->assertInstanceOf(GraphNode::class, $attached); + $this->assertSame('minted-after-the-fork', $attached->name); + $this->assertSame(42, $attached->counter); + + // A sibling forked afterwards attaches the same eight bytes + $sibling = $this->fork(static function () use ($arena, $address): int { + $siblingStore = PersistentStore::bootShared($arena); + $node = $siblingStore->attachObject($address); + + if (!$node instanceof GraphNode || $node->counter !== 42) { + return self::WRONG_SCALAR; + } + + return $node->name === 'minted-after-the-fork' ? self::OK : self::WRONG_STRING; + }); + $this->assertSame(self::OK, $this->await($sibling), 'a sibling could not attach the address'); + } + + public function testGraphsKeepTheirRegistryTablesInsideTheArena(): void + { + $store = $this->store(); + $arena = $this->arena(); + + $store->persist(AppConfig::class, $this->makeConfig('production', 3)); + + // The three registry tables are published under their names, and every one of them + // sits in the arena - the roots directory is all a forked child has to find them by + foreach ([ + ArenaRegistryLayout::ROOT_TABLE, + ArenaRegistryLayout::ROOT_ENTRIES, + ArenaRegistryLayout::ROOT_OBJECTS, + ] as $name) { + $address = $arena->findRoot($name); + $this->assertNotNull($address, "the registry did not publish {$name}"); + $this->assertTrue($arena->contains($address, 8), "{$name} does not live in the arena"); + } + } + + public function testArenaExhaustionDuringPersistIsATypedFailure(): void + { + // Room for the registry tables, nowhere near enough for a stream of graphs: the + // arena is bump-allocated, so re-persisting the same key consumes it steadily + $store = $this->isolatedStore('shared_arena_tiny', 256 * 1024, new ArenaRegistryLayout(8, 64)); + + $this->expectException(ArenaException::class); + $this->expectExceptionMessageMatches('/Shared arena exhausted/'); + + for ($index = 0; $index < 4096; $index++) { + $store->persist(AppConfig::class, $this->makeConfig("env-{$index}", $index)); + } + } + + public function testWatermarkIsVisibleToEveryProcessAndCountsWhatChildrenPersist(): void + { + $store = $this->store(); + $arena = $this->arena(); + + $store->persist(AppConfig::class, $this->makeConfig('production', 1)); + $before = $arena->watermark(); + + $pid = $this->fork(static function () use ($arena): int { + $childStore = PersistentStore::bootShared($arena); + $node = new GraphNode(); + $node->name = 'child-node'; + + $childStore->persist(GraphNode::class, $node); + + return self::OK; + }); + $this->assertSame(self::OK, $this->await($pid)); + + // The cursor lives in the arena: the parent sees the child's consumption + $this->assertGreaterThan($before, $arena->watermark()); + $this->assertSame($arena->size() - Arena::HEADER_SIZE - $arena->watermark(), $arena->remaining()); + } + + /** + * Boots a store on its own arena and its own persistent module + */ + private function isolatedStore(string $module, int $size, ArenaRegistryLayout $layout): PersistentStore + { + return PersistentStore::bootShared(Arena::create($size), $layout, $module); + } + + /** + * @param callable(): int $body + */ + private function fork(callable $body): int + { + $pid = pcntl_fork(); + $this->assertNotSame(-1, $pid, 'pcntl_fork() failed'); + + if ($pid > 0) { + return $pid; + } + + $code = self::CHILD_EXCEPTION; + + try { + $code = $body(); + } catch (\Throwable) { + // Reported as CHILD_EXCEPTION: a child must never print into the parent's run + } + + exit($code); + } + + private function await(int $pid): int + { + $status = 0; + pcntl_waitpid($pid, $status); + $this->assertTrue(pcntl_wifexited($status), "child {$pid} did not exit normally"); + + return pcntl_wexitstatus($status); + } + + /** + * @return array{0: resource, 1: resource} + */ + private function socketPair(): array + { + $pair = []; + $this->assertTrue(socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $pair), 'cannot create a socket pair'); + + return [$pair[0], $pair[1]]; + } +} diff --git a/tests/Shm/ArenaTest.php b/tests/Shm/ArenaTest.php new file mode 100644 index 0000000..dfc0624 --- /dev/null +++ b/tests/Shm/ArenaTest.php @@ -0,0 +1,243 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use PHPUnit\Framework\TestCase; + +/** + * Single-process behaviour of the arena: allocation, bookkeeping, roots and guards + * + * Cross-process behaviour - the whole point of the thing - is covered by ArenaForkTest. + * Arenas here are deliberately small: they are never unmapped before the process ends + * (only the creator unmaps, at shutdown), so a test that maps 64 MB per case would keep + * every one of them for the whole run. + */ +class ArenaTest extends TestCase +{ + private const int TEST_SIZE = 1 << 20; + + private function makeArena(?int $size = null): Arena + { + return Arena::create($size ?? self::TEST_SIZE); + } + + public function testFreshArenaStartsEmptyAboveItsHeader(): void + { + $arena = $this->makeArena(); + + $this->assertSame(self::TEST_SIZE, $arena->size()); + $this->assertSame(self::TEST_SIZE - Arena::HEADER_SIZE, $arena->capacity()); + $this->assertSame(0, $arena->watermark()); + $this->assertSame(self::TEST_SIZE - Arena::HEADER_SIZE, $arena->remaining()); + $this->assertTrue($arena->isCreator()); + $this->assertSame(getmypid(), $arena->creatorPid()); + } + + public function testAFreshArenaPassesItsOwnHeaderCheck(): void + { + $arena = $this->makeArena(); + + // Magic plus layout version: what a recovering worker verifies before it trusts a + // single offset inside the inherited mapping + $arena->assertIntact(); + + $this->assertSame(Arena::LAYOUT_VERSION, 1); + } + + public function testMutexSlotHoldsThisPlatformsMutex(): void + { + $arena = $this->makeArena(); + + // Measured, not assumed: 40 on x86-64/arm64 glibc, and never more than the slot + $this->assertGreaterThanOrEqual(Arena::MUTEX_SIZE_FLOOR, $arena->mutexSize()); + $this->assertLessThanOrEqual(Arena::MUTEX_SLOT_SIZE, $arena->mutexSize()); + $this->assertSame(Arena::MUTEX_COUNT - Arena::FIRST_STRIPE, $arena->stripeCount()); + } + + public function testAllocationsAreDisjointAlignedAndInsideTheArena(): void + { + $arena = $this->makeArena(); + + $first = $arena->allocate(24); + $second = $arena->allocate(24); + $page = $arena->allocate(8, 4096); + + $this->assertSame(0, $first % 16); + $this->assertSame(0, $second % 16); + $this->assertSame(0, $page % 4096); + $this->assertGreaterThanOrEqual($first + 24, $second); + $this->assertGreaterThanOrEqual($arena->baseAddress() + Arena::HEADER_SIZE, $first); + $this->assertLessThan($arena->baseAddress() + $arena->size(), $page); + } + + public function testWatermarkAccountsForEveryAllocationAndNeverFalls(): void + { + $arena = $this->makeArena(); + + $this->assertSame(0, $arena->watermark()); + $arena->allocate(1000); + $afterFirst = $arena->watermark(); + $this->assertGreaterThanOrEqual(1000, $afterFirst); + + $arena->allocate(1000); + $this->assertGreaterThanOrEqual($afterFirst + 1000, $arena->watermark()); + $this->assertSame($arena->size() - Arena::HEADER_SIZE - $arena->watermark(), $arena->remaining()); + } + + public function testExhaustionThrowsATypedExceptionAndKeepsTheArenaUsable(): void + { + $arena = $this->makeArena(); + $left = $arena->remaining(); + + try { + $arena->allocate($left + 1); + $this->fail('An over-sized allocation must not succeed'); + } catch (ArenaException $exception) { + $this->assertStringContainsString('Shared arena exhausted', $exception->getMessage()); + } + + // The refused allocation moved nothing: the arena is exactly as it was + $this->assertSame($left, $arena->remaining()); + $this->assertGreaterThan(0, $arena->allocate(16)); + } + + public function testAllocationRejectsNonsensicalSizeAndAlignment(): void + { + $arena = $this->makeArena(); + + $this->expectException(ArenaException::class); + $arena->allocate(64, 24); + } + + public function testWordAndByteAccessRoundTrip(): void + { + $arena = $this->makeArena(); + $address = $arena->allocate(64); + + $arena->writeWord($address, 0x0123456789); + $this->assertSame(0x0123456789, $arena->readWord($address)); + + $this->assertSame(11, $arena->writeBytes($address + 8, 'hello arena')); + $this->assertSame('hello arena', $arena->readBytes($address + 8, 11)); + } + + public function testAccessOutsideThePayloadIsRefused(): void + { + $arena = $this->makeArena(); + + $this->expectException(ArenaException::class); + // The header carries the cursor, the mutex bank and the roots directory: userland + // byte access must never reach it + $arena->writeWord($arena->baseAddress(), 1); + } + + public function testMisalignedWordAccessIsRefused(): void + { + $arena = $this->makeArena(); + $address = $arena->allocate(64); + + $this->expectException(ArenaException::class); + $arena->readWord($address + 1); + } + + public function testNamedRootsAreStoredLookedUpAndOverwritten(): void + { + $arena = $this->makeArena(); + $entries = $arena->allocate(64); + $objects = $arena->allocate(64); + + $arena->putRoot('registry.entries', $entries); + $arena->putRoot('registry.objects', $objects); + + $this->assertSame($entries, $arena->findRoot('registry.entries')); + $this->assertSame($objects, $arena->requireRoot('registry.objects')); + $this->assertNull($arena->findRoot('registry.nothing')); + $this->assertSame( + ['registry.entries' => $entries, 'registry.objects' => $objects], + $arena->roots(), + ); + + $arena->putRoot('registry.entries', $objects); + $this->assertSame($objects, $arena->findRoot('registry.entries')); + $this->assertCount(2, $arena->roots()); + } + + public function testUnknownRequiredRootThrows(): void + { + $arena = $this->makeArena(); + + $this->expectException(ArenaException::class); + $arena->requireRoot('registry.entries'); + } + + public function testRootNamesLongerThanTheDirectorySlotAreRefused(): void + { + $arena = $this->makeArena(); + + $this->expectException(ArenaException::class); + $arena->putRoot(str_repeat('n', Arena::ROOT_NAME_SIZE + 1), $arena->allocate(8)); + } + + public function testRootsDirectoryIsFixedSizeAndSaysSoWhenFull(): void + { + $arena = $this->makeArena(); + $address = $arena->allocate(8); + + for ($index = 0; $index < Arena::ROOT_CAPACITY; $index++) { + $arena->putRoot("root.{$index}", $address); + } + $this->assertCount(Arena::ROOT_CAPACITY, $arena->roots()); + + $this->expectException(ArenaException::class); + $arena->putRoot('one.too.many', $address); + } + + public function testStripeMutexesLockAndUnlockWithinTheirRange(): void + { + $arena = $this->makeArena(); + + $this->assertFalse($arena->lockStripe(Arena::FIRST_STRIPE)); + $arena->unlockStripe(Arena::FIRST_STRIPE); + + $this->assertTrue($arena->tryLockStripe(Arena::FIRST_STRIPE)); + $arena->unlockStripe(Arena::FIRST_STRIPE); + } + + public function testReservedMutexSlotsAreNotHandedOutAsStripes(): void + { + $arena = $this->makeArena(); + + $this->expectException(ArenaException::class); + $arena->lockStripe(Arena::ALLOCATOR_MUTEX); + } + + public function testSizeIsConfigurableThroughTheEnvironment(): void + { + putenv(Arena::SIZE_ENV . '=2M'); + + try { + $this->assertSame(2 * 1024 * 1024, Arena::configuredSize()); + } finally { + putenv(Arena::SIZE_ENV); + } + + $this->assertSame(Arena::DEFAULT_SIZE, Arena::configuredSize()); + } + + public function testUnalignedTotalSizeIsRefused(): void + { + $this->expectException(ArenaException::class); + Arena::create(Arena::HEADER_SIZE + 1); + } +} diff --git a/tests/Shm/MutableSharedForkTest.php b/tests/Shm/MutableSharedForkTest.php new file mode 100644 index 0000000..fb3820a --- /dev/null +++ b/tests/Shm/MutableSharedForkTest.php @@ -0,0 +1,593 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use Lisachenko\SharedData\PersistedObject; +use Lisachenko\SharedData\PersistentStore; +use Lisachenko\SharedData\Reclaimer; +use Lisachenko\SharedData\Stub\GraphNode; +use Lisachenko\SharedData\Stub\MutableCounter; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; + +/** + * Shared MUTABLE objects, exercised by real processes + * + * These are the claims of the validation sweep promoted to tests, so they are re-checked on + * both minors on every change instead of resting on a log file: + * + * - **S12** - one process writes, another reads, and the values are there. Two slots written + * in one critical section are never observed half-applied by a reader taking the same + * stripe lock; + * - **S16** - a string slot is a single aligned pointer, so a reader that skips the lock + * entirely still sees a complete string, old or new, and never a torn pointer; + * - **S14** - `handle` and `properties` are per-process. Two children attach the same objects + * without clobbering each other, the shared struct keeps a sentinel handle rather than + * anybody's real one, and a child that makes the engine cache a property bag inside a + * shared object leaves nothing behind for its sibling to dereference. + * + * Plus the lifecycle rules that only mean anything across processes: shared state is not + * rolled back at request end, a child cannot free arena memory, and a worker that simply + * exits without detaching does so cleanly. + */ +class MutableSharedForkTest extends TestCase +{ + private const int ARENA_SIZE = 32 << 20; + + private const string MODULE = 'shared_mutable'; + + /** + * Locked write/read rounds each child runs; the sweep's numbers were of this order and + * the whole point is that a rare interleaving has time to happen + */ + private const int ITERATIONS = 100_000; + + /** + * The only string values the writer ever publishes: an unlocked reader must see one of + * them, never a mixture and never a pointer into nowhere + */ + private const array LABELS = ['initial', 'alpha-label', 'beta-label', 'gamma-label']; + + private const int OK = 0; + private const int INCONSISTENT = 11; + private const int TORN_STRING = 12; + private const int NO_PROGRESS = 13; + private const int WRONG_HANDLE = 14; + private const int WRONG_SENTINEL = 15; + private const int FOREIGN_POINTER = 16; + private const int WRONG_VALUE = 17; + private const int NOT_REFUSED = 18; + private const int CHILD_EXCEPTION = 40; + + private static ?Arena $arena = null; + + private static ?PersistentStore $store = null; + + protected function setUp(): void + { + if (!\function_exists('pcntl_fork')) { + $this->markTestSkipped('ext-pcntl is required to exercise fork-shared memory'); + } + + // Loaded before any fork: a shared clone carries one class entry for the family + $this->assertTrue(class_exists(MutableCounter::class)); + $this->assertTrue(class_exists(GraphNode::class)); + } + + protected function tearDown(): void + { + self::$store?->detach(); + } + + private function store(): PersistentStore + { + self::$arena ??= Arena::create(self::ARENA_SIZE); + self::$store ??= PersistentStore::bootShared(self::$arena, null, self::MODULE); + + return self::$store; + } + + private function arena(): Arena + { + $this->store(); + \assert(self::$arena !== null); + + return self::$arena; + } + + /** + * Re-persists the mutable graph from scratch and returns the addresses of its two objects + * + * A storage key names a class, so the second object joins the graph as the peer of the + * first rather than under a key of its own - which is also the more honest shape: it makes + * every case work on a graph with an internal reference, exactly like a real one. + * + * @return array{0: int, 1: int} root address, peer address + */ + private function persistCounters(): array + { + $store = $this->store(); + + $root = new MutableCounter(); + $root->peer = new MutableCounter(); + + $shared = $store->persist(MutableCounter::class, $root, mutable: true); + \assert($shared->peer !== null); + + return [$store->sharedIdOf($shared), $store->sharedIdOf($shared->peer)]; + } + + public function testTwoChildrenWriteAndReadTheSameObjectUnderItsStripeLock(): void + { + $arena = $this->arena(); + $store = $this->store(); + [$address] = $this->persistCounters(); + + [$parentEnd, $childEnd] = $this->socketPair(); + + $writer = $this->fork(static function () use ($arena, $address): int { + $handle = PersistentStore::bootShared($arena, null, self::MODULE)->mutableHandle($address); + + for ($index = 1; $index <= self::ITERATIONS; $index++) { + // Both slots in ONE critical section: that is what makes "counter === mirror" + // an invariant a reader can rely on rather than a coincidence + $handle->writeScalars(['counter' => $index, 'mirror' => $index]); + + if ($index % 1000 === 0) { + $handle->writeString('label', self::LABELS[1 + intdiv($index, 1000) % 3]); + } + } + + return self::OK; + }); + + $reader = $this->fork(static function () use ($arena, $address, $childEnd): int { + $store = PersistentStore::bootShared($arena, null, self::MODULE); + $handle = $store->mutableHandle($address); + $instance = $store->attachObject($address); + \assert($instance instanceof MutableCounter); + + $reads = 0; + $highest = 0; + $deadline = microtime(true) + 60.0; + + while (microtime(true) < $deadline) { + $values = $handle->readScalars(['counter', 'mirror']); + $reads++; + + if ($values['counter'] !== $values['mirror']) { + return self::INCONSISTENT; + } + // The unlocked half (correction #2): one aligned pointer read of a slot whose + // type never changes gives a whole string, older or newer, but never a mixture + if (!\in_array($instance->label, self::LABELS, true)) { + return self::TORN_STRING; + } + \assert(\is_int($values['counter'])); + $highest = max($highest, $values['counter']); + + if ($highest >= self::ITERATIONS) { + break; + } + } + socket_write($childEnd, pack('PP', $reads, $highest), 16); + + return $highest > 0 ? self::OK : self::NO_PROGRESS; + }); + socket_close($childEnd); + + $report = (string) socket_read($parentEnd, 16, PHP_BINARY_READ); + socket_close($parentEnd); + + $this->assertSame(self::OK, $this->await($writer), 'the writing child failed'); + $this->assertSame(self::OK, $this->await($reader), 'the reading child saw an inconsistent object'); + $this->assertSame(16, \strlen($report), 'the reader did not report its counts'); + + /** @var array{1: int, 2: int} $counts */ + $counts = unpack('P2', $report); + $this->assertGreaterThan(1000, $counts[1], 'the reader barely ran, so it proves little'); + $this->assertGreaterThan(0, $counts[2], 'the reader never observed a value written by its sibling'); + + // And the parent, which did neither, sees where the writer stopped + $handle = $store->mutableHandle($address); + $this->assertSame(self::ITERATIONS, $handle->readScalar('counter')); + $this->assertSame(self::ITERATIONS, $handle->readScalar('mirror')); + $this->assertContains($handle->readString('label'), self::LABELS); + } + + public function testAChildWritesAStringAndAReferenceThatEveryOtherProcessCanFollow(): void + { + $arena = $this->arena(); + $store = $this->store(); + [$first, $second] = $this->persistCounters(); + + $child = $this->fork(static function () use ($arena, $first, $second): int { + $childStore = PersistentStore::bootShared($arena, null, self::MODULE); + $handle = $childStore->mutableHandle($first); + + $handle->writeString('note', 'written-by-the-child'); + + // Clearing and re-pointing: both halves of a reference write, and the target may + // only ever be another object of this very arena + $handle->writeReference('peer', null); + $handle->writeReference('peer', $childStore->attachObject($second)); + $childStore->mutableHandle($second)->writeScalar('counter', 4242); + + return self::OK; + }); + $this->assertSame(self::OK, $this->await($child)); + + // The bytes were interned in the arena by another process, and the reference is an + // address rather than anything that had to be encoded + $handle = $store->mutableHandle($first); + $this->assertSame('written-by-the-child', $handle->readString('note')); + + $peer = $handle->readReference('peer'); + $this->assertInstanceOf(MutableCounter::class, $peer); + $this->assertSame($second, $store->sharedIdOf($peer), 'the reference does not point at the shared peer'); + $this->assertSame(4242, $store->mutableHandle($second)->readScalar('counter')); + + // ... and the ordinary PHP read agrees with the synchronized one + $instance = $store->attachObject($first); + \assert($instance instanceof MutableCounter); + $this->assertSame('written-by-the-child', $instance->note); + $this->assertSame(4242, $instance->peer?->counter); + } + + public function testConcurrentAttachKeepsEveryHandlePerProcessAndTheSharedFieldASentinel(): void + { + $arena = $this->arena(); + $store = $this->store(); + [$counter] = $this->persistCounters(); + + $store->persist(GraphNode::class, new GraphNode('attach-node'), mutable: true); + $node = $store->addressOf(GraphNode::class); + $this->assertNotNull($node); + + // The two children overlap deliberately: the first one STAYS attached until the second + // has finished its checks, so both hold the same objects registered at the same time. + // It also keeps the second child clear of the one moment the shared handle field is + // not the sentinel - a detaching process puts its own handle back for the length of + // the store call that recycles its slot (see PersistentStore::releaseHandle()) + [$firstEnd, $secondEnd] = $this->socketPair(); + [$reportEnd, $writeEnd] = $this->socketPair(); + + $lingering = $this->fork(static function () use ($arena, $counter, $node, $firstEnd, $writeEnd): int { + $childStore = PersistentStore::bootShared($arena, null, self::MODULE); + + $childStore->attachObject($counter); + $childStore->attachObject($node); + + $handle = $childStore->processHandleOf($counter); + socket_write($writeEnd, pack('P', $handle ?? 0), 8); + + // Stays attached until its sibling says it is done + socket_read($firstEnd, 1, PHP_BINARY_READ); + + return $handle === null ? self::WRONG_HANDLE : self::OK; + }); + socket_close($writeEnd); + + $overlapping = $this->fork(static function () use ($arena, $counter, $node, $secondEnd): int { + $childStore = PersistentStore::bootShared($arena, null, self::MODULE); + + $first = $childStore->attachObject($counter); + $second = $childStore->attachObject($node); + + $firstHandle = $childStore->processHandleOf($counter); + $secondHandle = $childStore->processHandleOf($node); + + $code = self::OK; + + // Its OWN handles: two objects of one process never share a store slot + if ($firstHandle === null || $secondHandle === null || $firstHandle === $secondHandle) { + $code = self::WRONG_HANDLE; + } elseif ( + // ... while the shared struct carries a number no object store can produce, + // which is exactly why spl_object_id() cannot be an identity here + spl_object_id($first) !== PersistentStore::SHARED_HANDLE_SENTINEL + || spl_object_id($second) !== PersistentStore::SHARED_HANDLE_SENTINEL + ) { + $code = self::WRONG_SENTINEL; + } elseif ($childStore->sharedIdOf($first) === $childStore->sharedIdOf($second)) { + $code = self::WRONG_VALUE; + } + + socket_write($secondEnd, 'x', 1); + + return $code; + }); + + $reported = (string) socket_read($reportEnd, 8, PHP_BINARY_READ); + socket_close($reportEnd); + socket_close($firstEnd); + socket_close($secondEnd); + + $this->assertSame(self::OK, $this->await($overlapping), 'the overlapping child disagreed about its handles'); + $this->assertSame(self::OK, $this->await($lingering), 'the lingering child never got a handle'); + + $this->assertSame(8, \strlen($reported), 'the lingering child did not report its handle'); + /** @var array{1: int} $unpacked */ + $unpacked = unpack('P', $reported); + $this->assertGreaterThan(0, $unpacked[1], 'a child must hold a real object-store handle of its own'); + + // Nothing a child did leaked into the struct every process reads + $instance = $store->attachObject($counter); + $this->assertSame(PersistentStore::SHARED_HANDLE_SENTINEL, spl_object_id($instance)); + $this->assertSame($counter, $store->sharedIdOf($instance)); + $this->assertNotNull($store->processHandleOf($counter)); + $this->assertNotSame( + PersistentStore::SHARED_HANDLE_SENTINEL, + $store->processHandleOf($counter), + 'the side table must hold the real handle, not the sentinel', + ); + } + + public function testAChildInspectingASharedObjectLeavesNoHeapPointerForItsSibling(): void + { + $arena = $this->arena(); + $store = $this->store(); + [$address] = $this->persistCounters(); + + [$parentEnd, $childEnd] = $this->socketPair(); + [$siblingEnd, $signalEnd] = $this->socketPair(); + + $inspector = $this->fork(static function () use ($arena, $address, $childEnd, $signalEnd): int { + $childStore = PersistentStore::bootShared($arena, null, self::MODULE); + $instance = $childStore->attachObject($address); + + // The engine caches the rebuilt property bag inside the object - a pointer into + // THIS child's request heap, deposited in memory the whole family reads + get_object_vars($instance); + if ($childStore->dynamicPropertiesAddressOf($address) === 0) { + // Nothing was cached, so the rest of the case would prove nothing + return self::WRONG_VALUE; + } + $childStore->scrubProperties($address); + if ($childStore->dynamicPropertiesAddressOf($address) !== 0) { + return self::FOREIGN_POINTER; + } + + // The bracketed form does the same for a var_dump() + $childStore->inspect($address, static function (object $shared): void { + ob_start(); + var_dump($shared); + ob_end_clean(); + }); + if ($childStore->dynamicPropertiesAddressOf($address) !== 0) { + return self::FOREIGN_POINTER; + } + + socket_write($childEnd, 'x', 1); + socket_write($signalEnd, 'x', 1); + + return self::OK; + }); + socket_close($childEnd); + socket_close($signalEnd); + + $sibling = $this->fork(static function () use ($arena, $address, $siblingEnd): int { + // Runs strictly AFTER the inspector: this is the moment S14 crashed + socket_read($siblingEnd, 1, PHP_BINARY_READ); + + $childStore = PersistentStore::bootShared($arena, null, self::MODULE); + if ($childStore->dynamicPropertiesAddressOf($address) !== 0) { + return self::FOREIGN_POINTER; + } + $instance = $childStore->attachObject($address); + \assert($instance instanceof MutableCounter); + + if ($instance->label !== 'initial' || $instance->counter !== 0) { + return self::WRONG_VALUE; + } + + return self::OK; + }); + + $this->assertSame('x', (string) socket_read($parentEnd, 1, PHP_BINARY_READ)); + socket_close($parentEnd); + socket_close($siblingEnd); + + $this->assertSame(self::OK, $this->await($inspector), 'the inspecting child failed'); + $this->assertSame(self::OK, $this->await($sibling), 'a sibling found a foreign pointer inside the object'); + $this->assertSame(0, $store->dynamicPropertiesAddressOf($address)); + } + + public function testSharedMutableStateIsNeverRolledBackWhileAFrozenGraphStillIs(): void + { + $store = $this->store(); + [$address] = $this->persistCounters(); + + $store->mutableHandle($address)->writeScalars(['counter' => 7, 'ratio' => 1.5]); + $store->mutableHandle($address)->writeString('label', 'beta-label'); + + $store->detach(); + $store->attach(); + + $handle = $store->mutableHandle($address); + $this->assertSame(7, $handle->readScalar('counter'), 'a shared mutable graph was rolled back'); + $this->assertSame(1.5, $handle->readScalar('ratio')); + $this->assertSame('beta-label', $handle->readString('label')); + + // The frozen default, in the very same arena, still behaves exactly as it always has + $store->persist(GraphNode::class, new GraphNode('frozen'), mutable: false); + $frozen = $store->addressOf(GraphNode::class); + $this->assertNotNull($frozen); + + $instance = $store->attachObject($frozen); + \assert($instance instanceof GraphNode); + $instance->counter = 99; + + $store->detach(); + $store->attach(); + + $restored = $store->attachObject($frozen); + \assert($restored instanceof GraphNode); + $this->assertSame(0, $restored->counter, 'a frozen graph must still be restored from its snapshot'); + } + + public function testADirectWriteIsVisibleToSiblingsButItsHeapStringIsRepairedAtDetach(): void + { + $arena = $this->arena(); + $store = $this->store(); + [$address] = $this->persistCounters(); + + $store->mutableHandle($address)->writeString('label', 'gamma-label'); + + // Documented behaviour: a plain property write reaches shared memory, and for a SCALAR + // that is all it is - visible everywhere, and unsynchronized + $child = $this->fork(static function () use ($arena, $address): int { + $childStore = PersistentStore::bootShared($arena, null, self::MODULE); + $instance = $childStore->attachObject($address); + \assert($instance instanceof MutableCounter); + + $instance->counter = 31337; + + return self::OK; + }); + $this->assertSame(self::OK, $this->await($child)); + $this->assertSame(31337, $store->mutableHandle($address)->readScalar('counter')); + + // A direct STRING write is the dangerous half: the engine stores a request-heap + // zend_string pointer inside shared memory, which no sibling may follow + $instance = $store->attachObject($address); + \assert($instance instanceof MutableCounter); + $instance->label = 'written-without-the-api'; + $this->assertSame('written-without-the-api', $instance->label); + + $before = $store->repairedSlotCount(); + $store->detach(); + $this->assertGreaterThan($before, $store->repairedSlotCount(), 'the foreign pointer was left in the arena'); + + $store->attach(); + $this->assertSame( + 'initial', + $store->mutableHandle($address)->readString('label'), + 'the repaired slot must hold the persisted image, the only value known to be in the arena', + ); + $this->assertSame(31337, $store->mutableHandle($address)->readScalar('counter'), 'scalars are not rolled back'); + } + + public function testAChildIsRefusedWhenItTriesToFreeArenaMemory(): void + { + $arena = $this->arena(); + $store = $this->store(); + [$address] = $this->persistCounters(); + + $child = $this->fork(static function () use ($arena, $address): int { + PersistentStore::bootShared($arena, null, self::MODULE); + + if (!Reclaimer::isProtected($address)) { + return self::WRONG_VALUE; + } + + // A record over real arena blocks: exactly what a reclamation path would hold + $object = new PersistedObject( + $address, + Core::pointerAtAddress('zend_object *', $address), + Core::pointerAtAddress('char *', $address), + MutableCounter::class, + 'signature', + ); + + try { + Reclaimer::reclaimObject($object); + } catch (ArenaException) { + return self::OK; + } + + return self::NOT_REFUSED; + }); + $this->assertSame(self::OK, $this->await($child), 'a child was allowed to free arena memory'); + + // The refusal happens before anything is released, so the object is untouched + $instance = $store->attachObject($address); + \assert($instance instanceof MutableCounter); + $this->assertSame('initial', $instance->label); + + // ... and dropping a shared graph frees nothing either, whoever asks + $watermark = $arena->watermark(); + $this->assertTrue($store->drop(MutableCounter::class)); + $this->assertSame($watermark, $arena->watermark(), 'dropping a shared entry must not move the cursor'); + } + + public function testAWorkerThatExitsWithoutDetachingStillExitsCleanly(): void + { + $arena = $this->arena(); + [$address] = $this->persistCounters(); + + $pid = $this->fork(static function () use ($arena, $address): int { + $childStore = PersistentStore::bootShared($arena, null, self::MODULE); + $handle = $childStore->mutableHandle($address); + + $handle->writeScalars(['counter' => 5, 'flag' => true]); + $handle->writeString('label', 'alpha-label'); + + // Deliberately kept alive over the exit: request shutdown releases these AFTER + // the shutdown functions have run, which is where a teardown-ordering bug shows + $GLOBALS['shared_teardown_probe'] = $childStore->attachObject($address); + + return self::OK; + }); + + $status = 0; + pcntl_waitpid($pid, $status); + $this->assertFalse(pcntl_wifsignaled($status), 'the worker died from a signal during shutdown'); + $this->assertTrue(pcntl_wifexited($status), 'the worker did not exit normally'); + $this->assertSame(self::OK, pcntl_wexitstatus($status)); + } + + /** + * @param callable(): int $body + */ + private function fork(callable $body): int + { + $pid = pcntl_fork(); + $this->assertNotSame(-1, $pid, 'pcntl_fork() failed'); + + if ($pid > 0) { + return $pid; + } + + $code = self::CHILD_EXCEPTION; + + try { + $code = $body(); + } catch (\Throwable) { + // Reported as CHILD_EXCEPTION: a child must never print into the parent's run + } + + exit($code); + } + + private function await(int $pid): int + { + $status = 0; + pcntl_waitpid($pid, $status); + $this->assertTrue(pcntl_wifexited($status), "child {$pid} did not exit normally"); + + return pcntl_wexitstatus($status); + } + + /** + * @return array{0: resource, 1: resource} + */ + private function socketPair(): array + { + $pair = []; + $this->assertTrue(socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $pair), 'cannot create a socket pair'); + + return [$pair[0], $pair[1]]; + } +} diff --git a/tests/Shm/SharedMutationTest.php b/tests/Shm/SharedMutationTest.php new file mode 100644 index 0000000..25e0e12 --- /dev/null +++ b/tests/Shm/SharedMutationTest.php @@ -0,0 +1,346 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Shm; + +use Lisachenko\SharedData\PersistedObject; +use Lisachenko\SharedData\PersistentStore; +use Lisachenko\SharedData\Reclaimer; +use Lisachenko\SharedData\SharedMutationException; +use Lisachenko\SharedData\Stub\GraphNode; +use Lisachenko\SharedData\Stub\MutableCounter; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Type\ObjectEntry; +use ZEngine\Type\PersistentObjectFactory; + +/** + * The mutation contract of a shared graph, and what it refuses + * + * Everything here is a single process, because none of it is about concurrency: it is about + * what may be written into shared memory at all. The rule behind every refusal is the same - + * a slot of a shared object may hold a scalar, an arena-interned string or a pointer to + * another object of the same arena, and anything else is a pointer some other process cannot + * follow. Refusals therefore happen BEFORE a lock is taken and before a byte is written. + */ +class SharedMutationTest extends TestCase +{ + private const int ARENA_SIZE = 16 << 20; + + private const string MODULE = 'shared_mutation'; + private const string FROZEN_MODULE = 'shared_mutation_frozen'; + + private static ?Arena $arena = null; + + private static ?PersistentStore $store = null; + + private static ?PersistentStore $frozenStore = null; + + protected function tearDown(): void + { + self::$store?->detach(); + self::$frozenStore?->detach(); + } + + private function store(): PersistentStore + { + self::$arena ??= Arena::create(self::ARENA_SIZE); + self::$store ??= PersistentStore::bootShared(self::$arena, null, self::MODULE); + + return self::$store; + } + + /** + * The default, malloc-backed store: the mode every refusal below is contrasted against + */ + private function frozenStore(): PersistentStore + { + return self::$frozenStore ??= PersistentStore::boot(self::FROZEN_MODULE); + } + + private function persistCounter(): MutableCounter + { + $counter = new MutableCounter(); + + return $this->store()->persist(MutableCounter::class, $counter, mutable: true); + } + + public function testAMutableGraphNeedsTheArena(): void + { + $this->expectException(SharedMutationException::class); + $this->expectExceptionMessageMatches('/mutable graphs exist only in a fork-shared arena/'); + + $this->frozenStore()->persist(GraphNode::class, new GraphNode('heap'), mutable: true); + } + + public function testAFrozenGraphInTheArenaRefusesAWriteHandle(): void + { + $store = $this->store(); + $frozen = $store->persist(GraphNode::class, new GraphNode('frozen'), mutable: false); + + $this->assertTrue($store->isShared()); + $this->assertFalse($store->isMutable($frozen)); + + $this->expectException(SharedMutationException::class); + $this->expectExceptionMessageMatches('/belongs to a FROZEN graph/'); + + $store->mutableHandle($frozen); + } + + public function testOneObjectCannotBelongToAFrozenAndAMutableGraphAtOnce(): void + { + $store = $this->store(); + $shared = $this->persistCounter(); + + // A frozen graph reaching into the mutable one would roll its members back at request + // end - undoing, without a word, whatever another process wrote through them + $node = new GraphNode('reaching'); + $node->shared = null; + $holder = new GraphNode('holder'); + $holder->services = ['counter' => $shared]; + + $this->expectException(SharedMutationException::class); + $this->expectExceptionMessageMatches('/One object cannot be both/'); + + $store->persist(GraphNode::class, $holder, mutable: false); + } + + public function testTheSealedArrayPropertyStaysImmutableAndItsSlotRefusesEveryWrite(): void + { + $store = $this->store(); + $shared = $this->persistCounter(); + $handle = $store->mutableHandle($shared); + + $this->assertSame(['frozen' => true], $handle->read('sealed')); + $this->assertSame(['boxed' => 1], $handle->read('payload')); + + // A declared-array property is refused by the type check; an UNTYPED slot that happens + // to hold an array is refused by the slot itself, which is the rule that matters: + // a shared zend_array can never be replaced or grown, whatever the declaration says + $this->expectException(SharedMutationException::class); + $this->expectExceptionMessageMatches('/sealed shared array/'); + + $handle->writeScalar('payload', 1); + } + + public function testMutatingASealedArrayThroughPhpIsContainedRatherThanShared(): void + { + $store = $this->store(); + $shared = $this->persistCounter(); + + // The engine separates the immutable array into a REQUEST array and stores that + // pointer in the shared slot - which is precisely what must never survive the request + $shared->sealed['added'] = true; + $this->assertArrayHasKey('added', $shared->sealed); + + $before = $store->repairedSlotCount(); + $store->detach(); + $this->assertGreaterThan($before, $store->repairedSlotCount()); + + $store->attach(); + $this->assertSame( + ['frozen' => true], + $store->mutableHandle($store->addressOf(MutableCounter::class) ?? 0)->read('sealed'), + ); + } + + public function testDeclaredPropertyTypesAreEnforcedByTheWritePath(): void + { + $store = $this->store(); + $handle = $store->mutableHandle($this->persistCounter()); + + $handle->writeScalar('counter', 5); + $handle->writeString('label', 'still-a-string'); + + // int into a string property: the engine would have refused it, and the write path + // stores the value directly, so it refuses it here instead + try { + $handle->writeScalar('label', 7); + $this->fail('a typed property accepted a value of the wrong type'); + } catch (SharedMutationException $exception) { + $this->assertStringContainsString('is declared string', $exception->getMessage()); + } + + try { + $handle->writeString('counter', 'seven'); + $this->fail('an int property accepted a string'); + } catch (SharedMutationException $exception) { + $this->assertStringContainsString('is declared int', $exception->getMessage()); + } + + // null into a non-nullable property is the same mistake + $this->expectException(SharedMutationException::class); + $handle->writeScalar('label', null); + } + + public function testIntIsWidenedIntoAFloatPropertyExactlyAsTheEngineWould(): void + { + $handle = $this->store()->mutableHandle($this->persistCounter()); + + $handle->writeScalar('ratio', 3); + + $this->assertSame(3.0, $handle->readScalar('ratio')); + } + + public function testAReferenceMayOnlyPointAtAnotherObjectOfThisArena(): void + { + $store = $this->store(); + $handle = $store->mutableHandle($this->persistCounter()); + + try { + $handle->writeReference('peer', new MutableCounter()); + $this->fail('a request-heap object was accepted as a shared reference'); + } catch (SharedMutationException $exception) { + $this->assertStringContainsString('persist(', $exception->getMessage()); + } + + // The nullable half is legal, and so is pointing it back at a shared object + $handle->writeReference('peer', null); + $this->assertNull($handle->readReference('peer')); + } + + public function testAnUnknownPropertyAndAMisreadSlotAreBothTypedFailures(): void + { + $handle = $this->store()->mutableHandle($this->persistCounter()); + + try { + $handle->writeScalar('noSuchProperty', 1); + $this->fail('an undeclared property was accepted'); + } catch (SharedMutationException $exception) { + $this->assertStringContainsString('no property $noSuchProperty', $exception->getMessage()); + } + + $handle->writeScalar('counter', 12); + + $this->expectException(SharedMutationException::class); + $this->expectExceptionMessageMatches('/is not a string/'); + $handle->readString('counter'); + } + + public function testEveryScalarShapeSurvivesOneWriteAndOneRead(): void + { + $handle = $this->store()->mutableHandle($this->persistCounter()); + + $handle->writeScalars([ + 'counter' => -42, + 'mirror' => -42, + 'ratio' => 0.125, + 'flag' => true, + ]); + $handle->writeString('note', 'a note'); + + $this->assertSame( + ['counter' => -42, 'mirror' => -42, 'ratio' => 0.125, 'flag' => true], + $handle->readScalars(['counter', 'mirror', 'ratio', 'flag']), + ); + $this->assertSame('a note', $handle->readString('note')); + + $handle->writeScalar('flag', false); + $handle->writeScalar('note', null); + + $this->assertFalse($handle->readScalar('flag')); + $this->assertNull($handle->readString('note')); + $this->assertFalse($handle->wasLockRecovered(), 'no lock of this test was held by a dead owner'); + } + + public function testAMutableGraphKeepsThePinAndTheEngineFlagsOfAPersistentClone(): void + { + $store = $this->store(); + $shared = $this->persistCounter(); + + // Lifting the seals of the mutation surface does not lift the pin: the clone must stay + // invisible to refcounting and to the cycle collector exactly as a frozen one is + $entry = new ObjectEntry($shared); + $this->assertGreaterThanOrEqual(PersistentObjectFactory::PIN_BASELINE, $entry->getReferenceCount()); + $entry->release(); + + $this->assertTrue($store->isMutable($shared)); + $this->assertSame($store->addressOf(MutableCounter::class), $store->sharedIdOf($shared)); + } + + public function testIdentityIsTheArenaAddressAndNothingElse(): void + { + $store = $this->store(); + $shared = $this->persistCounter(); + + $this->assertSame(PersistentStore::SHARED_HANDLE_SENTINEL, spl_object_id($shared)); + $this->assertNotSame(0, $store->sharedIdOf($shared)); + + // An ordinary request object has no shared identity at all + try { + $store->sharedIdOf(new MutableCounter()); + $this->fail('a request object was given a shared identity'); + } catch (SharedMutationException $exception) { + $this->assertStringContainsString('not a shared object', $exception->getMessage()); + } + } + + public function testDroppingASharedGraphIsAllowedWhileAnAliasIsHeldAndFreesNothing(): void + { + $store = $this->store(); + $arena = self::$arena; + \assert($arena !== null); + + $shared = $this->persistCounter(); + $store->mutableHandle($shared)->writeScalar('counter', 3); + + $watermark = $arena->watermark(); + + // The alias predicate is disabled for shared graphs: a refcount in the arena is + // written by every process that ever copied the value, so it can neither prove nor + // disprove that this request still holds one - and there is nothing to protect, + // because dropping a shared entry frees no memory at all + $this->assertTrue($store->drop(MutableCounter::class)); + $this->assertSame($watermark, $arena->watermark()); + $this->assertFalse($store->has(MutableCounter::class)); + + // The alias is still readable: the bytes are simply still there + $this->assertSame(3, $shared->counter); + } + + public function testTheFrozenStoreStillRefusesToDropAGraphTheRequestCanReach(): void + { + $store = $this->frozenStore(); + $node = $store->persist(GraphNode::class, new GraphNode('aliased')); + + $this->assertNotNull($node); + + // Byte-identical to the behaviour before shared mode existed + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/still holds a reference/'); + + $store->drop(GraphNode::class); + } + + public function testArenaBlocksAreRefusedByEveryFreePathOfThisProcess(): void + { + $store = $this->store(); + $shared = $this->persistCounter(); + $addres = $store->sharedIdOf($shared); + + $this->assertTrue(Reclaimer::isProtected($addres)); + + $object = new PersistedObject( + $addres, + Core::pointerAtAddress('zend_object *', $addres), + Core::pointerAtAddress('char *', $addres), + MutableCounter::class, + 'signature', + ); + + $this->expectException(ArenaException::class); + $this->expectExceptionMessageMatches('/bump-allocated/'); + + Reclaimer::reclaimObject($object); + } +} diff --git a/tests/Stub/MutableCounter.php b/tests/Stub/MutableCounter.php new file mode 100644 index 0000000..6af3aca --- /dev/null +++ b/tests/Stub/MutableCounter.php @@ -0,0 +1,47 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Lisachenko\SharedData\Stub; + +/** + * The shape a shared MUTABLE graph is exercised with: one slot per contract rule + * + * `counter` and `mirror` are written together and read together, which is what makes a + * half-applied update observable at all; `label` is the string slot whose pointer is swapped; + * `peer` is the reference slot that may only ever point at another shared object; `sealed` + * is the array slot that must stay refused. + */ +class MutableCounter +{ + public int $counter = 0; + + public int $mirror = 0; + + public string $label = 'initial'; + + public ?string $note = null; + + public float $ratio = 0.0; + + public bool $flag = false; + + public ?MutableCounter $peer = null; + + public array $sealed = ['frozen' => true]; + + /** + * Untyped on purpose: the slot-level refusal of an array payload has to be reachable + * without the declared-type check answering first + */ + public mixed $payload = ['boxed' => 1]; +}