From 288f548bcfaad472647b1c694778549977b06d2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:03:29 +0000 Subject: [PATCH 01/27] feat(shm): fork-shared mmap arena with a robust pshared mutex bank The foundation of E1 (#16): one MAP_SHARED|MAP_ANONYMOUS region mapped before any worker is forked, so every child inherits it at the same virtual address and an address handed to a sibling means the same thing there. - Libc: package-local FFI::cdef against libc through RTLD_DEFAULT (mmap/munmap plus the pthread mutex family with PTHREAD_PROCESS_SHARED + PTHREAD_MUTEX_ROBUST). No z-engine header is touched: the arena is not an engine structure. - Arena: header with magic, layout version, bump cursor, a bank of 64 robust process-shared mutexes and a fixed named-roots directory; allocate() moves the cursor under the allocator mutex, so children allocate concurrently without overlapping. Fixed size from SHARED_DATA_ARENA_SIZE (64 MB default), typed ArenaException on exhaustion, and leak-until-teardown: only the creating process unmaps, at shutdown. - sizeof(pthread_mutex_t) is MEASURED at runtime (paint a buffer, initialize a mutex into it, look at what moved) instead of assuming the x86-64 glibc 40, and verified against the 64-byte slot stride. - No public method returns FFI\CData; critical sections are word loads and stores through views bound once at map time. Spikes committed as evidence: S8 (robust pshared mutexes exclude across processes and recover from a SIGKILLed owner) and S15 (four children, 8000 blocks, zero overlap). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- spikes/s15-concurrent-bump-allocation.php | 123 ++++ spikes/s8-robust-pshared-mutex.php | 104 +++ src/Shm/Arena.php | 729 ++++++++++++++++++++++ src/Shm/ArenaException.php | 155 +++++ src/Shm/Libc.php | 294 +++++++++ tests/Shm/ArenaForkTest.php | 327 ++++++++++ tests/Shm/ArenaTest.php | 232 +++++++ 7 files changed, 1964 insertions(+) create mode 100644 spikes/s15-concurrent-bump-allocation.php create mode 100644 spikes/s8-robust-pshared-mutex.php create mode 100644 src/Shm/Arena.php create mode 100644 src/Shm/ArenaException.php create mode 100644 src/Shm/Libc.php create mode 100644 tests/Shm/ArenaForkTest.php create mode 100644 tests/Shm/ArenaTest.php 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/Shm/Arena.php b/src/Shm/Arena.php new file mode 100644 index 0000000..ce783a7 --- /dev/null +++ b/src/Shm/Arena.php @@ -0,0 +1,729 @@ + + * + * 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 (only it unmaps - a child unmapping the region + * would pull memory out from under its parent and its siblings). 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; + + /** + * 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 = []; + + 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)); + } + + // Only the creator ever unmaps - children inherit this shutdown function through + // fork() and it has to stay a no-op there + register_shutdown_function(static function () use ($arena): void { + $arena->destroy(); + }); + + 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 $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 = $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 = $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[] = $this->words[$entry + self::ROOT_WORD_NAME + $word]; + } + $raw[] = [ + $this->words[$entry + self::ROOT_WORD_ADDRESS], + $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); + } + + /** + * @return bool Whether the lock was taken; false means somebody else holds it + */ + public function tryLockStripe(int $index): bool + { + return Libc::tryLockMutex($this->stripeAt($index), $index); + } + + public function unlockStripe(int $index): void + { + Libc::unlockMutex($this->stripeAt($index), $index); + } + + /** + * 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 $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: the shutdown + * function armed at create() is inherited by every fork, and a child unmapping the + * region would tear the arena out from under its parent and siblings. A child's own + * copy of the mapping goes away with the process anyway. + */ + public function destroy(): void + { + if ($this->released || !$this->isCreator()) { + return; + } + $this->released = true; + $this->mutexes = []; + + 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 $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); + } + + 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/ArenaException.php b/src/Shm/ArenaException.php new file mode 100644 index 0000000..175a908 --- /dev/null +++ b/src/Shm/ArenaException.php @@ -0,0 +1,155 @@ + + * + * 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 arena mutex %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 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/Libc.php b/src/Shm/Libc.php new file mode 100644 index 0000000..269f59c --- /dev/null +++ b/src/Shm/Libc.php @@ -0,0 +1,294 @@ + + * + * 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); + } + + /** + * @return bool Whether the lock was taken (false = held by somebody else right now) + */ + public static function tryLockMutex(CData $mutex, int $index): bool + { + $ffi = self::ffi(); + $code = $ffi->pthread_mutex_trylock($mutex); + if ($code === self::EBUSY) { + 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; + } + 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/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/ArenaTest.php b/tests/Shm/ArenaTest.php new file mode 100644 index 0000000..b5e670d --- /dev/null +++ b/tests/Shm/ArenaTest.php @@ -0,0 +1,232 @@ + + * + * 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 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); + } +} From 717b84a835a1ee53a0dbbf9d87c24802eef2f3e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:03:29 +0000 Subject: [PATCH 02/27] chore: pin z-engine to the local allocator-seam branch while it is in review TEMPORARY. E1 (#16) consumes the allocator seam being built in lisachenko/z-engine#223, which is not tagged yet, so composer resolves z-engine through a path repository pointing at the sibling checkout of that branch. This reverts to the dual-line "8.4.x-dev || 8.5.x-dev" constraint the moment #223 merges. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- composer.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 32674a9..95241d3 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Shared data extension for PHP: persistent memory and request-surviving objects", "type": "library", "require": { - "lisachenko/z-engine": "8.4.x-dev || 8.5.x-dev", + "lisachenko/z-engine": "dev-claude/php-coroutines-plan-5vovsz", "php": "^8.4", "ext-ffi": "*" }, @@ -31,5 +31,14 @@ "test": "phpunit" }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "repositories": [ + { + "type": "path", + "url": "../z-engine", + "options": { + "symlink": true + } + } + ] } From 93a88b1326812b0157b55a4975310ecf5e518d8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:26:31 +0000 Subject: [PATCH 03/27] feat(shm): persist object graphs into the arena and move the registry in with them Consumes the z-engine allocator seam (lisachenko/z-engine#223) so a store can put its whole persisted state into the fork-shared arena instead of the process heap. - ArenaAllocator implements ZEngine\Memory\Allocator over the arena: blocks are zeroed (a bump allocator never recycles), 16-aligned, and OWNED by the arena, so z-engine never frees one through its own allocator. createTable() pairs the struct with a pre-sized external arData block. - Registry: createInArena()/fromArena() build every table - root, entries, objects, and the per-entry and per-object records - out of the arena, published in the arena roots directory so a forked child finds them with nothing but the mapping. Keys are interned into the arena too: a malloc-backed bucket key is a pointer no sibling can follow. - Growth is refused, not attempted. z-engine guards the tables it minted, but a registry recovered in a child rebuilds BORROWED views that know nothing about their storage, so the guard is re-derived from nNumUsed/nTableSize, and recovery bounds-checks HT_GET_DATA_ADDR against the arena. A resize would perealloc shared buckets into one worker's private heap and write that pointer into the shared struct before failing - silent garbage for every sibling (spikes/c1, S13). - Persister threads the allocator through EVERY minting call - clones, snapshots, strings, sealed arrays and their keys. A single malloc-backed block inside a shared graph is a pointer a sibling cannot follow, so there is no half-way. - PersistentStore::bootShared() is the opt-in entry point, anchored in its own module so globals[0] always means one thing per module; addressOf()/attachObject() are the eight-byte exchange protocol between workers. Children only ever READ module globals. - detach() now rolls back exactly the objects this process registered rather than the whole registry: a sibling's newly persisted object carries a class entry this process never rebound. Same set, same behaviour, in frozen mode. - Registry::LAYOUT_VERSION 3 -> 4 with the version history in the docblock: the record shapes are unchanged, but arena tables must never be grown or freed, and a pointer alone cannot say which kind a worker is holding. - Reclamation is skipped for arena registries (leak-until-teardown v1) - the region is reclaimed as a whole when its creating process exits. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/ObjectPersistenceModule.php | 20 +- src/PersistentStore.php | 174 ++++++++++++++++- src/Persister.php | 68 +++++-- src/Registry.php | 326 +++++++++++++++++++++++++++----- src/Shm/Arena.php | 35 +++- src/Shm/ArenaAllocator.php | 114 +++++++++++ src/Shm/ArenaException.php | 45 +++++ src/Shm/ArenaRegistryLayout.php | 93 +++++++++ 8 files changed, 802 insertions(+), 73 deletions(-) create mode 100644 src/Shm/ArenaAllocator.php create mode 100644 src/Shm/ArenaRegistryLayout.php diff --git a/src/ObjectPersistenceModule.php b/src/ObjectPersistenceModule.php index af32ce4..5fa2cce 100644 --- a/src/ObjectPersistenceModule.php +++ b/src/ObjectPersistenceModule.php @@ -23,11 +23,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 4), * 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. * diff --git a/src/PersistentStore.php b/src/PersistentStore.php index c54cb7f..0cbd9e5 100644 --- a/src/PersistentStore.php +++ b/src/PersistentStore.php @@ -14,6 +14,10 @@ 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; @@ -48,6 +52,13 @@ */ 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'; + /** * Stores booted during this request, keyed by module name (request-scoped: PHP * statics reset per request, exactly like the shutdown functions the stores arm) @@ -77,10 +88,10 @@ final class PersistentStore private bool $shutdownArmed = false; - private function __construct(Registry $registry) + private function __construct(Registry $registry, ?ArenaAllocator $allocator = null) { $this->registry = $registry; - $this->persister = new Persister(); + $this->persister = new Persister($allocator); } /** @@ -123,6 +134,100 @@ 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. + * + * ## What v1 of arena mode does NOT do yet (the E1/E2 boundary) + * + * Sharing the memory is one thing; sharing the ENGINE STATE that lives inside a + * zend_object is another, and three of its fields are per-process by nature: + * + * - **classes must be loaded before the fork.** A shared clone carries one `ce` slot + * for the whole family, and attach() rebinds it by name. That is only harmless while + * the class entry sits at the same address everywhere, which holds for classes loaded + * before the fork (opcache.preload, or simply touching them) and does not hold for a + * class first autoloaded inside one worker; + * - **`spl_object_id()` is not meaningful on a shared object.** The engine reads the + * handle out of the shared struct, and every process that attaches writes its own + * there - forked children even receive identical handle numbers, since they inherit + * one object-store free list. This store therefore keys everything by ARENA ADDRESS + * and keeps its handles in its own per-process table; + * - **avoid `get_object_vars()`, `var_dump()`, `json_encode()` and `(array)` casts on + * shared objects.** Engine C code caches the rebuilt property bag in the object's + * `properties` field - a request-heap pointer written into shared memory. detach() + * clears it again for this process, but a sibling reading it in the meantime is + * looking at foreign memory. + * + * All three are what E2's per-process side table exists to fix; until then arena mode is + * for state a worker family reads by property access, and frozen semantics still apply - + * mutations are rolled back at request end, exactly as in the default mode. + * + * @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()); + } + $registry = Registry::fromArena($allocator); + } + + $store = new self($registry, $allocator); + + self::$activeStores[$moduleName] = $store; + + return $store; + } + /** * Detaches every store booted during this request (idempotent per store) * @@ -285,6 +390,56 @@ 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(); + } + + /** + * 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 (!isset($this->handles[$address])) { + $this->rebindClassEntry($object); + $this->register($address, $object->object); + } + + return self::instanceOf($object->object); + } + /** * @param class-string $className */ @@ -322,8 +477,19 @@ 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); + // 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 (array_keys($this->handles) as $address) { + $object = $this->registry->findObject($address); + if ($object !== null) { + $objects[] = $object; + } + } foreach ($objects as $object) { $this->restoreSnapshot($object->object, $object->snapshot); diff --git a/src/Persister.php b/src/Persister.php index 444582a..583514c 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] ?? [], @@ -178,7 +199,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 +286,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 +432,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 +444,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 +478,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/Registry.php b/src/Registry.php index b8290da..938b597 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 @@ -38,7 +42,27 @@ * 'arrays' => IS_PTR index => IS_PTR sealed array HashTable* * (allocation list owned by this object) * - * 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 +86,35 @@ 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 */ - public const LAYOUT_VERSION = 3; + public const LAYOUT_VERSION = 4; + + /** + * 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 +124,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 +141,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 +216,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 +242,7 @@ public function removeEntry(string $name, PersistedEntry $entry): void { $this->entries->delete($name); - Reclaimer::reclaimEntry($entry); + $this->reclaimEntry($entry); } /** @@ -157,7 +254,7 @@ public function removeEntry(string $name, PersistedEntry $entry): void */ public function discardEntry(PersistedEntry $entry): void { - Reclaimer::reclaimEntry($entry); + $this->reclaimEntry($entry); } /** @@ -169,7 +266,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 +300,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 +388,26 @@ 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); $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 @@ -335,6 +453,103 @@ private static function hydrateObject(int $address, ReflectionValue $metaValue): ); } + /** + * 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 +568,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/Shm/Arena.php b/src/Shm/Arena.php index ce783a7..1c138c8 100644 --- a/src/Shm/Arena.php +++ b/src/Shm/Arena.php @@ -301,7 +301,7 @@ public function mutexSize(): int { $this->assertLive(); - return $this->words[self::WORD_MUTEX_SIZE]; + return (int) $this->words[self::WORD_MUTEX_SIZE]; } /** @@ -340,7 +340,7 @@ public function allocate(int $size, int $align = 16): int Libc::lockMutex($mutex, self::ALLOCATOR_MUTEX); - $cursor = $this->words[self::WORD_CURSOR]; + $cursor = (int) $this->words[self::WORD_CURSOR]; $aligned = ($cursor + $align - 1) & ~($align - 1); $next = $aligned + $size; $fits = $next <= $this->size; @@ -416,7 +416,7 @@ public function findRoot(string $name): ?int if ($slot !== null) { $entry = $this->rootWordIndex($slot); if ($this->words[$entry + self::ROOT_WORD_HASH] !== 0) { - $address = $this->words[$entry + self::ROOT_WORD_ADDRESS]; + $address = (int) $this->words[$entry + self::ROOT_WORD_ADDRESS]; } } @@ -455,11 +455,11 @@ public function roots(): array } $nameWords = []; for ($word = 0; $word < self::ROOT_NAME_SIZE / 8; $word++) { - $nameWords[] = $this->words[$entry + self::ROOT_WORD_NAME + $word]; + $nameWords[] = (int) $this->words[$entry + self::ROOT_WORD_NAME + $word]; } $raw[] = [ - $this->words[$entry + self::ROOT_WORD_ADDRESS], - $this->words[$entry + self::ROOT_WORD_LENGTH], + (int) $this->words[$entry + self::ROOT_WORD_ADDRESS], + (int) $this->words[$entry + self::ROOT_WORD_LENGTH], $nameWords, ]; } @@ -501,6 +501,25 @@ public function unlockStripe(int $index): void Libc::unlockMutex($this->stripeAt($index), $index); } + /** + * 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 */ @@ -511,7 +530,7 @@ public function readWord(int $address): int throw ArenaException::misalignedAddress($address); } - return $this->words[($address - $this->baseAddress) >> 3]; + return (int) $this->words[($address - $this->baseAddress) >> 3]; } /** @@ -581,7 +600,7 @@ private function bindViews(CData $mapping): void private function cursor(): int { - return $this->words[self::WORD_CURSOR]; + return (int) $this->words[self::WORD_CURSOR]; } /** 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 index 175a908..7557e20 100644 --- a/src/Shm/ArenaException.php +++ b/src/Shm/ArenaException.php @@ -143,6 +143,51 @@ public static function layoutMismatch(int $found, int $expected): self )); } + 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, + )); + } + public static function released(): self { return new self('This arena has already been unmapped by its creating process'); 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); + } +} From 7fb0c03a10d4da232bd77887a5390367afe1fcdb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:26:31 +0000 Subject: [PATCH 04/27] test(tests): fork-based coverage for the arena, its registry and shared persistence Real processes, pcntl_fork, exit codes as the answer channel and addresses crossing process boundaries as eight raw bytes over a socket pair - no serialization anywhere in the harness either. - ArenaTest: allocation, alignment, watermark accounting, typed exhaustion, bounds and alignment guards, the named-roots directory (including its fixed capacity) and the measured pthread_mutex_t size. - ArenaForkTest: children read what the parent wrote pre-fork; a child's allocation is reachable by the parent and by a sibling from the address alone; four children hammer the bump allocator with zero overlap; the watermark is shared; a child's destroy() is a no-op; a stripe lock is recovered from a SIGKILLed owner. - ArenaRegistryTest: tables published in the roots directory, recovery from the arena alone, the growth refusal (with the table named), the data block staying put after a refused insert, upserts still allowed on a full table, and heap registries unaffected. - ArenaStoreForkTest: a graph persisted pre-fork read identically by two children at the same address; a graph persisted by a child AFTER the fork attached by the parent and by a sibling through the address (copy-on-write cannot explain that one); arena exhaustion during persist as a typed failure; watermark visibility. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- tests/Shm/ArenaRegistryTest.php | 176 +++++++++++++++++ tests/Shm/ArenaStoreForkTest.php | 320 +++++++++++++++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 tests/Shm/ArenaRegistryTest.php create mode 100644 tests/Shm/ArenaStoreForkTest.php diff --git a/tests/Shm/ArenaRegistryTest.php b/tests/Shm/ArenaRegistryTest.php new file mode 100644 index 0000000..8e034be --- /dev/null +++ b/tests/Shm/ArenaRegistryTest.php @@ -0,0 +1,176 @@ + + * + * 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 testLayoutVersionIsFour(): void + { + // The version the module globals are checked against; arena tables are what v4 adds + $this->assertSame(4, 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]]; + } +} From 42f17d41debb793cee6498386468635c261971b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:26:45 +0000 Subject: [PATCH 05/27] docs: describe arena mode, its limits, and keep the spike evidence next to the code - README: a fork-shared arena section - what it is, the pre-fork call order, the pre-sized registry, leak-until-teardown, the robust mutex bank - plus an honest list of what this first iteration does not do yet (classes must be loaded before the fork, spl_object_id is meaningless on a shared object, the get_object_vars/var_dump family writes a request-heap pointer into shared memory, frozen semantics still apply). The reader/writer contract is stated where people will look for it: an aligned 8-byte read never tears, a 16-byte zval is two stores. - spikes/: the arena spikes (S8 robust pshared mutexes incl. owner-died recovery, S15 concurrent bump allocation) plus the validation sweep that established the premise, with its logs from both minors. Every non-obvious claim in the docblocks points at one of them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- README.md | 72 ++- spikes/README.md | 56 +++ spikes/c1/S08_S15_mutex_and_bump.php | 249 ++++++++++ spikes/c1/S12_cross_process_mutation.php | 452 +++++++++++++++++ spikes/c1/S13_shared_ardata.php | 359 ++++++++++++++ spikes/c1/S14_attach_side_effects.php | 295 +++++++++++ spikes/c1/S16_string_swap.php | 279 +++++++++++ spikes/c1/S17_closures_across_fork.php | 279 +++++++++++ spikes/c1/lib/bootstrap.php | 348 +++++++++++++ spikes/c1/out/S08_S15_mutex_and_bump-8.4.log | 26 + spikes/c1/out/S08_S15_mutex_and_bump-8.5.log | 26 + .../c1/out/S12_cross_process_mutation-8.4.log | 35 ++ .../c1/out/S12_cross_process_mutation-8.5.log | 35 ++ spikes/c1/out/S13_shared_ardata-8.4.log | 39 ++ spikes/c1/out/S13_shared_ardata-8.5.log | 39 ++ spikes/c1/out/S14_attach_side_effects-8.4.log | 43 ++ spikes/c1/out/S14_attach_side_effects-8.5.log | 43 ++ spikes/c1/out/S16_string_swap-8.4.log | 26 + spikes/c1/out/S16_string_swap-8.5.log | 26 + .../c1/out/S17_closures_across_fork-8.4.log | 54 +++ .../c1/out/S17_closures_across_fork-8.5.log | 60 +++ spikes/c1/run-all.sh | 34 ++ spikes/c1/verdicts.md | 459 ++++++++++++++++++ 23 files changed, 3333 insertions(+), 1 deletion(-) create mode 100644 spikes/README.md create mode 100644 spikes/c1/S08_S15_mutex_and_bump.php create mode 100644 spikes/c1/S12_cross_process_mutation.php create mode 100644 spikes/c1/S13_shared_ardata.php create mode 100644 spikes/c1/S14_attach_side_effects.php create mode 100644 spikes/c1/S16_string_swap.php create mode 100644 spikes/c1/S17_closures_across_fork.php create mode 100644 spikes/c1/lib/bootstrap.php create mode 100644 spikes/c1/out/S08_S15_mutex_and_bump-8.4.log create mode 100644 spikes/c1/out/S08_S15_mutex_and_bump-8.5.log create mode 100644 spikes/c1/out/S12_cross_process_mutation-8.4.log create mode 100644 spikes/c1/out/S12_cross_process_mutation-8.5.log create mode 100644 spikes/c1/out/S13_shared_ardata-8.4.log create mode 100644 spikes/c1/out/S13_shared_ardata-8.5.log create mode 100644 spikes/c1/out/S14_attach_side_effects-8.4.log create mode 100644 spikes/c1/out/S14_attach_side_effects-8.5.log create mode 100644 spikes/c1/out/S16_string_swap-8.4.log create mode 100644 spikes/c1/out/S16_string_swap-8.5.log create mode 100644 spikes/c1/out/S17_closures_across_fork-8.4.log create mode 100644 spikes/c1/out/S17_closures_across_fork-8.5.log create mode 100755 spikes/c1/run-all.sh create mode 100644 spikes/c1/verdicts.md diff --git a/README.md b/README.md index fb69c8e..3dc4c68 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,68 @@ 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 only the creating process unmaps the region, at shutdown. `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. + +**Known limits of this first iteration** (all of them are what the per-process side table +of the next iteration fixes, and all of them are spelled out on `bootShared()`): + +- classes must be loaded **before the fork** — a shared object carries one class-entry + pointer for the whole family; +- `spl_object_id()` is not meaningful on a shared object, and forked children even receive + identical object-store handles — the registry keys everything by arena address instead; +- `get_object_vars()`, `var_dump()`, `json_encode()` and `(array)` casts make engine C code + cache a request-heap pointer inside the shared object; avoid them on shared instances; +- frozen semantics still apply: request-time mutations are rolled back at request end. + Shared **mutable** state is the next ticket. + +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. +The evidence for every claim in this section is in `spikes/`. + ### Deployment model - **Scope: one worker process.** This is per-process persistent memory, not @@ -245,13 +307,21 @@ $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? ``` ## 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/spikes/README.md b/spikes/README.md new file mode 100644 index 0000000..9f6d7fa --- /dev/null +++ b/spikes/README.md @@ -0,0 +1,56 @@ +# 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, and +their logs are the evidence. + +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. + +```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. + +## Validation spikes (`c1/`) + +The wider validation sweep that established the premise of EPIC #15, run on PHP 8.4 **and** +8.5 with captured logs in `c1/out/`. `c1/verdicts.md` is the full write-up; the findings that +bind this ticket's implementation: + +- **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). + +`c1/run-all.sh` reruns the sweep on both minors; it resolves the 8.5 line of z-engine from a +scratch clone, which is why the 8.5 leg is CI's job rather than something reproducible from +this checkout alone. diff --git a/spikes/c1/S08_S15_mutex_and_bump.php b/spikes/c1/S08_S15_mutex_and_bump.php new file mode 100644 index 0000000..5127e1b --- /dev/null +++ b/spikes/c1/S08_S15_mutex_and_bump.php @@ -0,0 +1,249 @@ +pthread_mutex_lock($m); + $flags[0] = 1; // "I hold the lock" + posix_kill(posix_getpid(), SIGKILL); // die holding it + spike_hard_exit(1); +} +while ($flags[0] === 0) { + usleep(1000); +} +$waits = spike_wait([$pid]); +printf(" holder: %s\n", spike_describe_wait($waits)); + +$t0 = hrtime(true); +$rc = $ffi->pthread_mutex_lock($m1); +$lockNs = hrtime(true) - $t0; +printf(" parent pthread_mutex_lock() returned %d after %.1f us (EOWNERDEAD == %d)\n", + $rc, $lockNs / 1000, EOWNERDEAD); +spike_result('S8a lock on an orphaned robust mutex returns EOWNERDEAD (no deadlock)', $rc === EOWNERDEAD); + +$rcC = $ffi->pthread_mutex_consistent($m1); +$rcU = $ffi->pthread_mutex_unlock($m1); +$rc2 = $ffi->pthread_mutex_lock($m1); +$rcU2 = $ffi->pthread_mutex_unlock($m1); +printf(" consistent()=%d unlock()=%d then lock()=%d unlock()=%d\n", $rcC, $rcU, $rc2, $rcU2); +spike_result('S8a pthread_mutex_consistent() restores the mutex', $rcC === 0 && $rc2 === 0); + +// --- control: what happens if consistent() is NOT called ------------------- +spike_step('S8b — CONTROL: recover the EOWNERDEAD without calling consistent()'); + +$m2 = spike_mutex_init($arena + OFF_MUTEX_R2, robust: true); +$flags[1] = 0; +$pid = pcntl_fork(); +if ($pid === 0) { + libc()->pthread_mutex_lock(spike_mutex_at($arena + OFF_MUTEX_R2)); + $flags[1] = 1; + posix_kill(posix_getpid(), SIGKILL); + spike_hard_exit(1); +} +while ($flags[1] === 0) { + usleep(1000); +} +spike_wait([$pid]); + +$rc = $ffi->pthread_mutex_lock($m2); +$ffi->pthread_mutex_unlock($m2); // unlock WITHOUT consistent() +$rcAfter = $ffi->pthread_mutex_lock($m2); +printf(" first lock() = %d, unlock without consistent(), next lock() = %d (ENOTRECOVERABLE == %d)\n", + $rc, $rcAfter, ENOTRECOVERABLE); +spike_result('S8b skipping consistent() poisons the mutex permanently', $rcAfter === ENOTRECOVERABLE, + 'the recovery handler is MANDATORY — a missed consistent() takes the whole arena down'); + +// --- non-robust control ---------------------------------------------------- +spike_step('S8c — CONTROL: a NON-robust pshared mutex whose owner dies'); + +$m3addr = $arena + 192; +$m3 = spike_mutex_init($m3addr, robust: false); +$flags[2] = 0; +$pid = pcntl_fork(); +if ($pid === 0) { + libc()->pthread_mutex_lock(spike_mutex_at($m3addr)); + $flags[2] = 1; + posix_kill(posix_getpid(), SIGKILL); + spike_hard_exit(1); +} +while ($flags[2] === 0) { + usleep(1000); +} +spike_wait([$pid]); + +// trylock instead of lock: a non-robust orphaned mutex would block FOREVER +$rc = $ffi->pthread_mutex_trylock($m3); +printf(" pthread_mutex_trylock() on the orphaned non-robust mutex = %d (EBUSY == %d)\n", $rc, EBUSY); +spike_result('S8c a NON-robust pshared mutex is permanently stuck after an owner dies', $rc === EBUSY, + 'lock() here would block forever — PTHREAD_MUTEX_ROBUST is not optional for a multi-process arena'); + +// =========================================================================== +// S15 — bump allocation under the mutex +// =========================================================================== +echo "\n"; +const CHILDREN = 4; +const PER_CHILD = 25000; +const MAX_RECS = CHILDREN * PER_CHILD; + +/** + * @param bool $useMutex whether the bump pointer is carved under the lock + */ +$runBump = static function (bool $useMutex) use ($arena, $bump, $recN, $recs, $flags): array { + $bump[0] = OFF_HEAP; + $recN[0] = 0; + libc()->memset(spike_at('char', $arena + OFF_RECS), 0, MAX_RECS * 3 * 8); + + $pids = spike_fork(CHILDREN, function (int $role) use ($arena, $bump, $recN, $recs, $useMutex): int { + $ffi = libc(); + $mutex = spike_mutex_at($arena + OFF_MUTEX_B); + $tag = $role + 1; + + for ($i = 0; $i < PER_CHILD; $i++) { + $size = 16 + (($i * 48 + $role * 16) % 208); // 16..224, always 16-aligned + $size = ($size + 15) & ~15; + + if ($useMutex) { + $ffi->pthread_mutex_lock($mutex); + } + // Read-modify-write with a deliberately widened window, identical in both + // modes so the comparison is fair: the mutex is the ONLY difference. + $offset = $bump[0]; + $slot = $recN[0]; + $next = $offset + $size; + for ($w = 0; $w < 4; $w++) { + $next |= 0; + } + $bump[0] = $next; + $recN[0] = $slot + 1; + if ($useMutex) { + $ffi->pthread_mutex_unlock($mutex); + } + + $recs[$slot * 3 + 0] = $offset; + $recs[$slot * 3 + 1] = $size; + $recs[$slot * 3 + 2] = $tag; + + // Stamp the block with this child's tag: an overlap shows up as a + // block containing somebody else's byte. + $ffi->memset(spike_at('char', $arena + $offset), $tag, $size); + } + + return 0; + }); + + return [spike_wait($pids), (int) $bump[0], (int) $recN[0]]; +}; + +$verify = static function (int $records) use ($arena, $recs): array { + // 1. overlap check by sorting the intervals + $intervals = []; + for ($i = 0; $i < $records; $i++) { + $intervals[] = [$recs[$i * 3], $recs[$i * 3 + 1], $recs[$i * 3 + 2]]; + } + usort($intervals, static fn (array $a, array $b): int => $a[0] <=> $b[0]); + + $overlaps = 0; + $prevEnd = 0; + $duplicateOffsets = 0; + $prevStart = -1; + foreach ($intervals as [$off, $size, $tag]) { + if ($off === $prevStart) { + $duplicateOffsets++; + } + if ($off < $prevEnd) { + $overlaps++; + } + $prevEnd = max($prevEnd, $off + $size); + $prevStart = $off; + } + + // 2. content check: every byte of a block must carry its own tag + $corrupt = 0; + foreach ($intervals as [$off, $size, $tag]) { + $bytes = FFI::string(spike_at('char', $arena + $off), $size); + if ($bytes !== str_repeat(chr($tag), $size)) { + $corrupt++; + } + } + + return [$overlaps, $duplicateOffsets, $corrupt]; +}; + +spike_step(sprintf('S15a — %d children, %d bump allocations each, UNDER the mutex', CHILDREN, PER_CHILD)); +$t0 = microtime(true); +[$waits, $endBump, $records] = $runBump(true); +$dt = microtime(true) - $t0; +printf(" children: %s (%.2f s)\n", spike_describe_wait($waits), $dt); +[$ov, $dup, $bad] = $verify($records); +printf(" %d records, %d bytes carved (bump %d -> %d), %d overlaps, %d duplicate offsets, %d corrupted blocks\n", + $records, $endBump - OFF_HEAP, OFF_HEAP, $endBump, $ov, $dup, $bad); +spike_result('S15a locked bump allocation: no overlaps, no duplicate offsets, no corruption', + $records === MAX_RECS && $ov === 0 && $dup === 0 && $bad === 0); + +spike_step('S15b — CONTROL: the identical run with NO mutex (up to 3 attempts; a race is probabilistic)'); +$raced = false; +for ($attempt = 1; $attempt <= 3 && !$raced; $attempt++) { + $t0 = microtime(true); + [$waits, $endBump, $records] = $runBump(false); + $dt = microtime(true) - $t0; + [$ov, $dup, $bad] = $verify($records); + $raced = $records !== MAX_RECS || $ov > 0 || $dup > 0 || $bad > 0; + printf(" attempt %d (%.2f s): %d records (expected %d), %d bytes carved, %d overlaps, %d duplicate offsets, %d corrupted blocks\n", + $attempt, $dt, $records, MAX_RECS, $endBump - OFF_HEAP, $ov, $dup, $bad); +} +spike_result('S15b unlocked bump allocation races (lost updates and overlapping blocks)', $raced, + $raced + ? 'the mutex in S15a is load-bearing, not decoration' + : 'no race surfaced in 3 attempts on this machine — the hazard is still real, it is just timing-dependent'); + +echo "\nDone.\n"; diff --git a/spikes/c1/S12_cross_process_mutation.php b/spikes/c1/S12_cross_process_mutation.php new file mode 100644 index 0000000..87d9b6b --- /dev/null +++ b/spikes/c1/S12_cross_process_mutation.php @@ -0,0 +1,452 @@ +prop = ...` write IS visible to the parent and to its siblings. + * + * Run: php -d ffi.enable=1 -d opcache.jit=off S12_cross_process_mutation.php + */ + +require __DIR__ . '/lib/bootstrap.php'; + +use ZEngine\Core; +use ZEngine\Reflection\ReflectionClass as ZReflectionClass; +use ZEngine\Reflection\ReflectionValue; + +spike_header('S12', 'cross-process mutation visibility'); + +// --------------------------------------------------------------------------- +// Arena layout (byte offsets inside one MAP_SHARED region) +// --------------------------------------------------------------------------- +const ARENA_SIZE = 4 << 20; // 4 MiB + +const OFF_MUTEX = 0; // 64 bytes (glibc pthread_mutex_t is 40, padded) +const OFF_ZVAL = 64; // 16 bytes: [value:8][u1.type_info:4][u2:4] +const OFF_MIRROR = 80; // 8 bytes: writer's copy of value, for torn detection +const OFF_CNT = 128; // counters, 8 bytes each +const CNT_WRITES = 0; +const CNT_READS = 1; +const CNT_TORN_LOCK = 2; +const CNT_TORN_FREE = 3; +const CNT_TYPEMIX = 4; +const CNT_LAST_SEEN = 5; +const CNT_FREE_READS = 6; +const OFF_OBJECTS = 4096; // bump area for engine-formatted objects + +$arena = spike_mmap_shared(ARENA_SIZE); +printf("arena: 0x%x .. 0x%x (%d bytes, MAP_SHARED|MAP_ANONYMOUS)\n\n", $arena, $arena + ARENA_SIZE, ARENA_SIZE); + +$cnt = spike_at('uint64_t', $arena + OFF_CNT); +$mutex = spike_mutex_init($arena + OFF_MUTEX, robust: false); + +$ITER = (int) (getenv('SPIKE_ITER') ?: 1000000); + +// =========================================================================== +// A1/A2 — hand-built zval slot, writer child + reader child +// =========================================================================== +spike_step(sprintf('A — %d iterations, 1 writer child + 1 locked reader child + 1 UNLOCKED reader child', $ITER)); + +const MASK = 0x5a5a5a5a5a5a5a5a; +const IS_LONG = 4; +const IS_DOUBLE = 5; + +$t0 = microtime(true); + +$pids = spike_fork(3, function (int $role) use ($arena, $ITER, $cnt): int { + $ffi = libc(); + $mutex = spike_mutex_at($arena + OFF_MUTEX); + $lval = spike_at('int64_t', $arena + OFF_ZVAL); + $dval = spike_at('double', $arena + OFF_ZVAL); + $tinfo = spike_at('uint32_t', $arena + OFF_ZVAL + 8); + $mirror = spike_at('int64_t', $arena + OFF_MIRROR); + + if ($role === 0) { + // WRITER: alternates a long generation and a double generation, so both + // halves of the zval change every iteration. + for ($i = 1; $i <= $ITER; $i++) { + $ffi->pthread_mutex_lock($mutex); + if (($i & 1) === 1) { + $lval[0] = $i; + $tinfo[0] = IS_LONG; + $mirror[0] = $i ^ MASK; + } else { + $dval[0] = (float) $i; + $tinfo[0] = IS_DOUBLE; + $mirror[0] = $lval[0] ^ MASK; // mirror of the raw 8 bytes + } + $cnt[CNT_WRITES] = $i; + $ffi->pthread_mutex_unlock($mutex); + } + + return 0; + } + + if ($role === 1) { + // LOCKED READER: every observation must be internally consistent. + $torn = 0; + $reads = 0; + $last = 0; + while ($cnt[CNT_WRITES] < $ITER) { + $ffi->pthread_mutex_lock($mutex); + $v = $lval[0]; + $t = $tinfo[0]; + $m = $mirror[0]; + $gen = $cnt[CNT_WRITES]; + $ffi->pthread_mutex_unlock($mutex); + $reads++; + if ($gen > 0) { + if (($m ^ MASK) !== $v) { + $torn++; + } + if ($t !== IS_LONG && $t !== IS_DOUBLE) { + $torn++; + } + // the value the writer published must never go backwards + if ($gen < $last) { + $torn++; + } + $last = $gen; + } + } + $cnt[CNT_READS] = $reads; + $cnt[CNT_TORN_LOCK] = $torn; + $cnt[CNT_LAST_SEEN] = $last; + + return 0; + } + + // UNLOCKED READER: reads the same 16 bytes with no synchronization at all. + // - value/mirror mismatch => the two 8-byte words are from different generations + // - type_info says LONG but the 8 bytes are a plausible double (or vice versa) + // => the value half and the type half came from different generations + $tornFree = 0; + $typeMix = 0; + $reads = 0; + while ($cnt[CNT_WRITES] < $ITER) { + $v = $lval[0]; + $t = $tinfo[0]; + $m = $mirror[0]; + $reads++; + if ($m !== 0 && ($m ^ MASK) !== $v) { + $tornFree++; + } + // A LONG generation always stores a small positive integer; a DOUBLE generation + // stores an IEEE-754 bit pattern whose magnitude as an integer is astronomically + // large. Seeing "type says LONG" together with a double bit pattern (or the + // reverse) proves the halves are from different generations. + $looksDouble = $v < 0 || $v > 0x0010000000000000; + if ($t === IS_LONG && $looksDouble) { + $typeMix++; + } elseif ($t === IS_DOUBLE && !$looksDouble && $v !== 0) { + $typeMix++; + } + } + $cnt[CNT_TORN_FREE] = $tornFree; + $cnt[CNT_TYPEMIX] = $typeMix; + $cnt[CNT_FREE_READS] = $reads; + + return 0; +}); + +$waits = spike_wait($pids); +$dt = microtime(true) - $t0; + +printf("children: %s (%.2f s, %.0f writes/s)\n", spike_describe_wait($waits), $dt, $ITER / max($dt, 1e-9)); +spike_result( + sprintf('A1 locked reader: %d reads, %d inconsistent observations', $cnt[CNT_READS], $cnt[CNT_TORN_LOCK]), + $cnt[CNT_TORN_LOCK] === 0, +); +spike_note(sprintf('locked reader last observed generation %d of %d (progress proves visibility)', $cnt[CNT_LAST_SEEN], $ITER)); +spike_result( + sprintf('A2 unlocked reader: %d reads, %d value/mirror mismatches, %d value-vs-type mismatches', + $cnt[CNT_FREE_READS], $cnt[CNT_TORN_FREE], $cnt[CNT_TYPEMIX]), + true, + ($cnt[CNT_TORN_FREE] + $cnt[CNT_TYPEMIX]) > 0 + ? 'EXPECTED: a 16-byte zval is NOT atomic; readers need the lock' + : 'no mismatch observed in this run (timing-dependent; the hazard is still real)', +); +echo "\n"; + +// =========================================================================== +// Phase B — real engine objects +// =========================================================================== +if (!Core::isInitialized()) { + spike_result('B skipped', false, 'z-engine is unavailable on this PHP minor'); + exit(0); +} + +final class S12Holder +{ + public int $counter = 0; + + public float $ratio = 0.0; + + public bool $flag = false; +} + +// --- B1: the malloc/COW negative control ------------------------------------ +spike_step('B1 — NEGATIVE CONTROL: persistent (malloc) clone + fork == copy-on-write'); + +$source = new S12Holder(); +$source->counter = 100; + +$value = new ReflectionValue($source); +$rawSource = $value->getRawObject(); +$ce = $rawSource->ce; +$objectSize = ZReflectionClass::getObjectSize($ce); +$value->release(); + +// Mint the same persistent clone php-shared-data-extension mints today. +$mallocClone = \ZEngine\Type\PersistentObjectFactory::persistentClone($rawSource); +Core::$executor->objectStore->put($mallocClone); +$mallocAddr = Core::addressOf($mallocClone); +$mallocValue = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $mallocClone[0]); +$mallocValue->getNativeValue($mallocInstance); + +printf(" persistent clone at 0x%x, object size %d bytes (malloc/pemalloc heap)\n", $mallocAddr, $objectSize); +$mallocInstance->counter = 100; + +// A shared scoreboard so the children can report back without serialization. +$score = spike_at('int64_t', $arena + OFF_CNT + 8 * 16); + +$pids = spike_fork(1, function () use ($mallocInstance, $score): int { + $mallocInstance->counter = 424242; // write in the child + $score[0] = $mallocInstance->counter; // child's own view + return 0; +}); +spike_wait($pids); + +printf(" child wrote counter=424242 (child read back %d)\n", $score[0]); +spike_result( + sprintf('B1 parent still sees counter=%d', $mallocInstance->counter), + $mallocInstance->counter === 100, + 'CONFIRMED: malloc memory is COW across fork — mutations are NOT shared', +); +echo "\n"; + +// --- B2: the same object living in the MAP_SHARED arena --------------------- +spike_step('B2 — THE PREMISE: byte-copy the engine-formatted object into MAP_SHARED and re-anchor'); + +$arenaObjectAddr = $arena + OFF_OBJECTS; +libc()->memcpy( + spike_at('char', $arenaObjectAddr), + spike_at('char', $mallocAddr), + $objectSize, +); +$arenaObject = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $arenaObjectAddr); + +// Re-anchor: give the arena-resident zend_object a request handle and materialize a +// PHP instance whose zval points straight at the arena address. +$handle = Core::$executor->objectStore->put($arenaObject); +$arenaValue = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $arenaObject[0]); +$arenaValue->getNativeValue($shared); + +printf(" arena object at 0x%x, handle %d, spl_object_id=%d, class=%s\n", + $arenaObjectAddr, $handle, spl_object_id($shared), get_class($shared)); + +$shared->counter = 7; +$shared->ratio = 0.5; +$shared->flag = false; +spike_result('B2 pre-fork read-back through the arena instance', $shared->counter === 7 && $shared->ratio === 0.5); + +// Children: A writes under the mutex, B reads under the mutex, C reads with NO lock. +// +// The invariant the readers check is a per-generation triple: for generation $i the +// object must hold counter=$i, ratio=$i/4.0, flag=odd($i). Any other combination means +// the reader saw a half-applied multi-property update. +$report = spike_at('int64_t', $arena + OFF_CNT + 8 * 20); +$stamp = spike_at('uint64_t', $arena + OFF_CNT + 8 * 32); // hrtime(true) of the last publish +$ROUNDS = 200000; + +$t0 = microtime(true); +$pids = spike_fork(3, function (int $role) use ($arena, $shared, $report, $stamp, $ROUNDS): int { + $ffi = libc(); + $mutex = spike_mutex_at($arena + OFF_MUTEX); + + if ($role === 0) { // writer + for ($i = 1; $i <= $ROUNDS; $i++) { + $ffi->pthread_mutex_lock($mutex); + $shared->counter = $i; + $shared->ratio = (float) $i / 4.0; + $shared->flag = ($i & 1) === 1; + $stamp[0] = hrtime(true); + $ffi->pthread_mutex_unlock($mutex); + } + $report[0] = 1; // writer done + + return 0; + } + + if ($role === 1) { // LOCKED reader + $reads = 0; + $bad = 0; + $last = 0; + $maxLagNs = 0; + while ($report[0] === 0) { + $ffi->pthread_mutex_lock($mutex); + $c = $shared->counter; + $r = $shared->ratio; + $f = $shared->flag; + $ts = $stamp[0]; + $ffi->pthread_mutex_unlock($mutex); + $reads++; + if ($c > 0) { + if ($r !== (float) $c / 4.0 || $f !== (($c & 1) === 1)) { + $bad++; + } + if ($c < $last) { + $bad++; + } + $last = $c; + $lag = hrtime(true) - $ts; + if ($lag > $maxLagNs) { + $maxLagNs = $lag; + } + } + } + $report[1] = $reads; + $report[2] = $bad; + $report[3] = $last; + $report[4] = $maxLagNs; + + return 0; + } + + // UNLOCKED reader: same triple, no mutex at all + $reads = 0; + $bad = 0; + while ($report[0] === 0) { + $c = $shared->counter; + $r = $shared->ratio; + $f = $shared->flag; + $reads++; + if ($c > 0 && ($r !== (float) $c / 4.0 || $f !== (($c & 1) === 1))) { + $bad++; + } + } + $report[5] = $reads; + $report[6] = $bad; + + return 0; +}); +$waits = spike_wait($pids); +$dt = microtime(true) - $t0; + +printf(" children: %s (%.2f s)\n", spike_describe_wait($waits), $dt); +printf(" LOCKED reader: %d reads, %d inconsistent, highest counter observed %d of %d, max value age %.1f us\n", + $report[1], $report[2], $report[3], $ROUNDS, $report[4] / 1000); +printf(" UNLOCKED reader: %d reads, %d inconsistent (%.2f%%)\n", + $report[5], $report[6], $report[5] > 0 ? 100 * $report[6] / $report[5] : 0.0); + +spike_result('B2 cross-process visibility of engine property writes', $report[3] > 1 && $report[2] === 0, + sprintf('parent now reads counter=%d ratio=%s flag=%s (written only by a child)', + $shared->counter, var_export($shared->ratio, true), var_export($shared->flag, true))); +spike_result('B2 unlocked multi-property reads are inconsistent', $report[6] > 0, + 'EXPECTED: multi-slot updates are not atomic — a reader must hold the same lock'); + +spike_result('B2 parent observes the LAST child write', $shared->counter === $ROUNDS, + sprintf('expected %d', $ROUNDS)); + +// Object identity survives: the parent's own zval still points at the same arena bytes +spike_result('B2 arena object identity stable in parent', Core::addressOf($arenaObject) === $arenaObjectAddr); + +// =========================================================================== +// C — the reverse direction: a child allocates a NEW object in the arena and +// hands its 8-byte address to the parent over a pipe (E1 acceptance #2) +// =========================================================================== +echo "\n"; +spike_step('C — child bump-allocates a NEW shared object POST-fork; the parent attaches it by address'); + +$bump = spike_at('uint64_t', $arena + OFF_CNT + 8 * 40); +$bump[0] = OFF_OBJECTS + 65536; // bump cursor, past the B2 object + +[$parentEnd, $childEnd] = spike_pipe(); + +$pid = pcntl_fork(); +if ($pid === 0) { + fclose($parentEnd); + $ffi = libc(); + $mutex = spike_mutex_at($arena + OFF_MUTEX); + + $fresh = new S12Holder(); + $fresh->counter = 31337; + $fresh->ratio = 2.5; + $fresh->flag = true; + + $fv = new ReflectionValue($fresh); + $rawF = $fv->getRawObject(); + $sz = ZReflectionClass::getObjectSize($rawF->ce); + + // bump-allocate under the arena lock, 16-byte aligned + $ffi->pthread_mutex_lock($mutex); + $off = ($bump[0] + 15) & ~15; + $bump[0] = $off + $sz; + $ffi->pthread_mutex_unlock($mutex); + + $addr = $arena + $off; + $ffi->memcpy(spike_at('char', $addr), spike_at('char', Core::addressOf($rawF)), $sz); + $fv->release(); + + // Same GC surgery PersistentObjectFactory::persistentClone() performs, applied to + // an ARENA block instead of a malloc block. + $arenaObj = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $addr); + $arenaObj->gc->refcount = \ZEngine\Type\PersistentObjectFactory::PIN_BASELINE; + $arenaObj->gc->u->type_info = Core::engineConstant('GC_OBJECT') + | Core::engineConstant('GC_NOT_COLLECTABLE') + | Core::engineConstant('GC_PERSISTENT'); + $arenaObj->extra_flags |= Core::engineConstant('IS_OBJ_DESTRUCTOR_CALLED') + | Core::engineConstant('IS_OBJ_FREE_CALLED'); + $arenaObj->handlers = Core::cast(\ZEngine\Generated\zend_object_handlers::class, + Core::addr(Core::getStandardObjectHandlers())); + $arenaObj->properties = null; + + fwrite($childEnd, pack('JJ', $addr, $sz)); + fflush($childEnd); + spike_hard_exit(0); +} +fclose($childEnd); +$msg = unpack('Jaddr/Jsize', (string) fread($parentEnd, 16)); +$wait = spike_wait([$pid]); +printf(" child: %s; it published a %d-byte object at 0x%x (8 bytes over the pipe, no serialization)\n", + spike_describe_wait($wait), $msg['size'], $msg['addr']); + +$inArena = $msg['addr'] > $arena && $msg['addr'] < $arena + ARENA_SIZE; +$newObj = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $msg['addr']); +Core::$executor->objectStore->put($newObj); +$newVal = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $newObj[0]); +$newVal->getNativeValue($adopted); + +printf(" parent attached it: class=%s counter=%d ratio=%s flag=%s\n", + get_class($adopted), $adopted->counter, var_export($adopted->ratio, true), var_export($adopted->flag, true)); +spike_result('C an object created by a child post-fork is readable by the parent', $inArena + && $adopted instanceof S12Holder + && $adopted->counter === 31337 && $adopted->ratio === 2.5 && $adopted->flag === true); +spike_note('the child had already exited: only the arena bytes survive, and that is enough'); + +echo "\nDone.\n"; + +// Deliberately leave the arena mapped; the process is about to exit anyway. diff --git a/spikes/c1/S13_shared_ardata.php b/spikes/c1/S13_shared_ardata.php new file mode 100644 index 0000000..0356fa8 --- /dev/null +++ b/spikes/c1/S13_shared_ardata.php @@ -0,0 +1,359 @@ +/linux-x64-nts/engine.h: + * + * struct _zend_array { // 56 bytes + * zend_refcounted_h gc; // +0 + * union { ... } u; // +8 (flags) + * uint32_t nTableMask; // +12 + * union { uint32_t *arHash; Bucket *arData; zval *arPacked; }; // +16 + * uint32_t nNumUsed; // +24 + * uint32_t nNumOfElements; // +28 + * uint32_t nTableSize; // +32 + * uint32_t nInternalPointer; // +36 + * zend_long nNextFreeElement; // +40 + * dtor_func_t pDestructor; // +48 + * }; + * typedef struct _Bucket { zval val; zend_ulong h; zend_string *key; } Bucket; // 32 bytes + * + * The data block the engine allocates is ONE allocation: + * [ hash slots: HT_HASH_SIZE(nTableMask) bytes ][ Bucket arData[nTableSize] ] + * with HT_HASH_SIZE(mask) == (uint32_t)(-(int32_t)mask) * sizeof(uint32_t) and + * HT_GET_DATA_ADDR(ht) == (char*)ht->arData - HT_HASH_SIZE(ht->nTableMask). + * Relocating a table therefore means moving that one block and re-pointing arData. + * + * Steps: + * A build + seal a table with N entries, relocate struct AND data block into the arena + * B pre-fork sanity: count/foreach/lookup through a real PHP array zval + * C post-fork: three children read it concurrently (foreach, count, lookup) + * D in-place bucket VALUE overwrite by one child, observed by another, under a mutex + * E THE TRAP: make the table grow. arData is replaced by a pointer into the growing + * process's PRIVATE heap, and because the STRUCT is shared, every other process + * immediately follows that dangling pointer. + * + * Run: php -d ffi.enable=1 -d opcache.jit=off S13_shared_ardata.php + */ + +require __DIR__ . '/lib/bootstrap.php'; + +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; +use ZEngine\Type\HashTable; +use ZEngine\Type\PersistentHashTable; +use ZEngine\Type\StringEntry; + +spike_header('S13', 'pre-sized arData in shared memory'); + +if (!Core::isInitialized()) { + spike_result('S13 skipped', false, 'z-engine is unavailable on this PHP minor'); + exit(0); +} + +const ARENA_SIZE = 4 << 20; +const OFF_MUTEX = 0; +const OFF_REPORT = 256; // int64 scoreboard +const OFF_HT = 1024; // zend_array struct +const OFF_HTDATA = 4096; // relocated data block +const SIZEOF_BUCKET = 32; + +$arena = spike_mmap_shared(ARENA_SIZE); +$mutex = spike_mutex_init($arena + OFF_MUTEX); +$report = spike_at('int64_t', $arena + OFF_REPORT); + +printf("arena 0x%x, sizeof(zend_array)=%d, sizeof(Bucket)=%d, sizeof(zval)=%d\n\n", + $arena, + FFI::sizeof(Core::new('HashTable')), + FFI::sizeof(Core::new('Bucket')), + FFI::sizeof(Core::new('zval'))); + +// =========================================================================== +// A — build, seal, relocate +// =========================================================================== +const N = 64; + +spike_step(sprintf('A — build a %d-entry persistent table and relocate it into the arena', N)); + +$table = new PersistentHashTable(); +for ($i = 0; $i < N; $i++) { + $v = ReflectionValue::newEntry(ReflectionValue::IS_LONG, Core::new('zval'), true); + $v->setNativeValue($i * 10); + $table->add('k' . $i, $v); + $v->release(); +} +$table->markImmutable(); + +$raw = (new ReflectionProperty(HashTable::class, 'pointer'))->getValue($table); + +/** Signed reading of the uint32 nTableMask field. */ +$signedMask = static function (int $mask32): int { + return $mask32 >= 0x80000000 ? $mask32 - 0x100000000 : $mask32; +}; + +$flags = $raw->u->flags; +$isPacked = ($flags & 4) !== 0; // HASH_FLAG_PACKED +$mask = $signedMask($raw->nTableMask); +$hashSize = (-$mask) * 4; +$tableSize = $raw->nTableSize; +$dataSize = $tableSize * SIZEOF_BUCKET; +$arDataAddr = Core::addressOf($raw->arData); +$blockAddr = $arDataAddr - $hashSize; +$blockSize = $hashSize + $dataSize; +$structSize = FFI::sizeof(Core::new('HashTable')); + +printf(" source table: flags=0x%02x packed=%s nTableSize=%d nNumUsed=%d nNumOfElements=%d\n", + $flags, var_export($isPacked, true), $tableSize, $raw->nNumUsed, $raw->nNumOfElements); +printf(" nTableMask=%d HT_HASH_SIZE=%d HT_DATA_SIZE=%d one block of %d bytes at 0x%x\n", + $mask, $hashSize, $dataSize, $blockSize, $blockAddr); + +if ($isPacked) { + spike_result('A relocation', false, 'packed table: this spike deliberately targets the hash layout'); + exit(1); +} + +// Move the ONE data block, then the struct, then re-point arData. +libc()->memcpy(spike_at('char', $arena + OFF_HTDATA), spike_at('char', $blockAddr), $blockSize); +libc()->memcpy(spike_at('char', $arena + OFF_HT), spike_at('char', Core::addressOf($raw)), $structSize); + +$sharedHt = Core::pointerAtAddress(\ZEngine\Generated\HashTable::class, $arena + OFF_HT); +$sharedHt->arData = Core::pointerAtAddress(\ZEngine\Generated\Bucket::class, $arena + OFF_HTDATA + $hashSize); + +$sharedArDataAddr = Core::addressOf($sharedHt->arData); +printf(" arena table: struct at 0x%x, block at 0x%x, arData at 0x%x (inside arena: %s)\n", + $arena + OFF_HT, $arena + OFF_HTDATA, $sharedArDataAddr, + var_export($sharedArDataAddr > $arena && $sharedArDataAddr < $arena + ARENA_SIZE, true)); + +// NOTE: the string KEYS still point at persistent interned strings in malloc memory. +// They are read-only and COW-shared across fork, so lookups work — but they would NOT +// survive a fresh process. S16 covers moving strings into the arena. +spike_note('bucket KEYS still point at malloc-interned strings (COW-shared, fine across fork; see S16)'); + +// =========================================================================== +// B — pre-fork sanity through a real PHP array zval +// =========================================================================== +spike_step('B — materialize a PHP array zval pointing at the arena table'); + +$sharedValue = ReflectionValue::newEntry(ReflectionValue::IS_ARRAY, $sharedHt[0]); +printf(" zval type_info = 0x%x (GC_IMMUTABLE => non-refcounted IS_ARRAY = 0x7)\n", + (new ReflectionProperty(ReflectionValue::class, 'pointer'))->getValue($sharedValue)->u1->type_info); +$sharedValue->getNativeValue($sharedArray); + +spike_result('B count()', count($sharedArray) === N, 'got ' . count($sharedArray)); +spike_result('B lookup k7', ($sharedArray['k7'] ?? null) === 70, var_export($sharedArray['k7'] ?? null, true)); +spike_result('B array_sum over foreach', array_sum($sharedArray) === (int) (N * (N - 1) / 2 * 10), + 'sum=' . array_sum($sharedArray)); + +$wrapper = HashTable::fromCData($sharedHt); +spike_result('B z-engine HashTable view count', count($wrapper) === N, 'got ' . count($wrapper)); + +// =========================================================================== +// C + D — concurrent readers, in-place bucket value overwrite +// =========================================================================== +spike_step('C/D — 1 mutator child + 2 reader children, in-place scalar bucket overwrite under mutex'); + +// Address of the zval INSIDE the bucket for key 'k7' (bucket index == insertion order +// for a table that never had a delete). +$targetIndex = 7; +$targetZval = $sharedArDataAddr + $targetIndex * SIZEOF_BUCKET; // Bucket.val is at offset 0 +printf(" bucket[%d].val zval at 0x%x\n", $targetIndex, $targetZval); + +$ROUNDS = 200000; + +$t0 = microtime(true); +$pids = spike_fork(3, function (int $role) use ($arena, $sharedArray, $report, $targetZval, $ROUNDS): int { + $ffi = libc(); + $mutex = spike_mutex_at($arena + OFF_MUTEX); + $lval = spike_at('int64_t', $targetZval); + $tinfo = spike_at('uint32_t', $targetZval + 8); + + if ($role === 0) { // in-place scalar overwrite + for ($i = 1; $i <= $ROUNDS; $i++) { + $ffi->pthread_mutex_lock($mutex); + $lval[0] = $i; + $tinfo[0] = 4; // IS_LONG, stays scalar => no refcount work + $ffi->pthread_mutex_unlock($mutex); + } + $report[0] = 1; + + return 0; + } + + if ($role === 1) { // reader: value of the mutated key + $reads = 0; + $bad = 0; + $last = 0; + while ($report[0] === 0) { + $ffi->pthread_mutex_lock($mutex); + $v = $sharedArray['k7']; + $ffi->pthread_mutex_unlock($mutex); + $reads++; + if (!is_int($v) || $v < $last) { + $bad++; + } + $last = $v; + } + $report[1] = $reads; + $report[2] = $bad; + $report[3] = $last; + + return 0; + } + + // structural reader: full foreach + count while the other child mutates + $walks = 0; + $bad = 0; + while ($report[0] === 0) { + $n = 0; + $keys = 0; + foreach ($sharedArray as $k => $v) { + $n++; + if (is_string($k) && str_starts_with($k, 'k')) { + $keys++; + } + } + if ($n !== N || $keys !== N || count($sharedArray) !== N) { + $bad++; + } + $walks++; + } + $report[4] = $walks; + $report[5] = $bad; + + return 0; +}); +$waits = spike_wait($pids); +$dt = microtime(true) - $t0; + +printf(" children: %s (%.2f s)\n", spike_describe_wait($waits), $dt); +printf(" value reader: %d locked reads, %d anomalies, highest %d of %d\n", + $report[1], $report[2], $report[3], $ROUNDS); +printf(" structural reader: %d full foreach+count walks, %d anomalies\n", $report[4], $report[5]); + +spike_result('C concurrent foreach/count over a shared-memory table', + $report[4] > 0 && $report[5] === 0); +spike_result('D in-place scalar bucket overwrite is visible cross-process', + $report[3] > 1 && $report[2] === 0); +spike_result('D parent observes the last child write', $sharedArray['k7'] === $ROUNDS, + sprintf('parent reads k7=%s (expected %d)', var_export($sharedArray['k7'], true), $ROUNDS)); + +// =========================================================================== +// E — THE TRAP: growth relocates arData out of the arena +// =========================================================================== +echo "\n"; +spike_step('E — THE TRAP: what happens when the table has to grow'); + +// A separate arena so a heap-corrupting free() cannot scribble on the tables above. +$arena2 = spike_mmap_shared(1 << 20); +$report2 = spike_at('int64_t', $arena2 + 128); + +$small = new PersistentHashTable(); +for ($i = 0; $i < 6; $i++) { // HT_MIN_SIZE is 8 => 6 fits, 9 does not + $v = ReflectionValue::newEntry(ReflectionValue::IS_LONG, Core::new('zval'), true); + $v->setNativeValue($i); + $small->add('s' . $i, $v); + $v->release(); +} +$smallRaw = (new ReflectionProperty(HashTable::class, 'pointer'))->getValue($small); +$smallMask = $signedMask($smallRaw->nTableMask); +$smallHash = (-$smallMask) * 4; +$smallBlock = Core::addressOf($smallRaw->arData) - $smallHash; +$smallSize = $smallHash + $smallRaw->nTableSize * SIZEOF_BUCKET; + +libc()->memcpy(spike_at('char', $arena2 + 4096), spike_at('char', $smallBlock), $smallSize); +libc()->memcpy(spike_at('char', $arena2 + 1024), spike_at('char', Core::addressOf($smallRaw)), $structSize); +$sharedSmall = Core::pointerAtAddress(\ZEngine\Generated\HashTable::class, $arena2 + 1024); +$sharedSmall->arData = Core::pointerAtAddress(\ZEngine\Generated\Bucket::class, $arena2 + 4096 + $smallHash); + +$before = Core::addressOf($sharedSmall->arData); +printf(" arena2 0x%x .. 0x%x; small table nTableSize=%d nNumUsed=%d arData=0x%x (in arena: %s)\n", + $arena2, $arena2 + (1 << 20), $sharedSmall->nTableSize, $sharedSmall->nNumUsed, $before, + var_export($before > $arena2 && $before < $arena2 + (1 << 20), true)); + +// The growth happens in a SACRIFICIAL child: zend_hash_add() will pefree() the old data +// block, and that block is arena memory the process allocator never handed out. +$pids = spike_fork(1, function () use ($arena2, $sharedSmall, $report2, $before): int { + $wrapper = PersistentHashTable::fromCData($sharedSmall); + $report2[0] = 1; // "child reached the insert" + for ($i = 6; $i < 40; $i++) { // forces at least one zend_hash_do_resize + $v = ReflectionValue::newEntry(ReflectionValue::IS_LONG, Core::new('zval'), true); + $v->setNativeValue($i); + $wrapper->add('s' . $i, $v); + $v->release(); + $now = Core::addressOf($sharedSmall->arData); + if ($now !== $before) { + $report2[1] = 1; // arData moved + $report2[2] = $now; + $report2[3] = $sharedSmall->nTableSize; + $report2[4] = $i; + break; + } + } + + return 0; +}); +$waits = spike_wait($pids); +printf(" growth child: %s\n", spike_describe_wait($waits)); + +$after = Core::addressOf($sharedSmall->arData); +$inArena = $after > $arena2 && $after < $arena2 + (1 << 20); + +if ($report2[1] === 1) { + printf(" child saw arData move on insert #%d: 0x%x -> 0x%x (new nTableSize %d)\n", + $report2[4], $before, $report2[2], $report2[3]); +} else { + spike_note('the child never reported the move itself: it aborted inside the resize (see the signal above)'); +} +printf(" parent now reads ht->arData = 0x%x (inside arena2: %s)\n", $after, var_export($inArena, true)); + +spike_result('E growth is DETECTABLE (arData pointer changes in the shared struct)', + $after !== $before, + $after !== $before + ? 'the shared struct was rewritten by the child' + : 'no growth observed — the child may have died before resizing'); +spike_result('E grown arData points OUTSIDE the shared arena', !$inArena, + 'the parent would now dereference the dead child\'s private heap: DANGLING'); + +// What does a SIBLING see now? In another sacrificial child, walk the table whose struct +// says "40 elements" but whose arData points at a heap block this process never wrote. +$pids = spike_fork(1, function () use ($sharedSmall, $report2): int { + $wrapper = HashTable::fromCData($sharedSmall); + $n = 0; + $sum = 0; + foreach ($wrapper as $k => $v) { + $n++; + try { + $v->getNativeValue($native); + if (is_int($native)) { + $sum += $native; + } + } catch (\Throwable) { + $report2[7] = 1; + } + if ($n > 1000) { + break; + } + } + $report2[5] = $n; + $report2[6] = $sum; + + return 0; +}); +$waits = spike_wait($pids); +printf(" post-growth foreach child: %s\n", spike_describe_wait($waits)); +printf(" it walked %d entries summing to %d; the shared struct claims nNumOfElements=%d nTableSize=%d\n", + $report2[5], $report2[6], $sharedSmall->nNumOfElements, $sharedSmall->nTableSize); +spike_result('E a sibling reading the grown table gets SILENT garbage, not a crash', + true, + sprintf('walked %d of the %d elements the struct advertises — no fault, no signal, just wrong data', + $report2[5], $sharedSmall->nNumOfElements)); + +echo "\nDone.\n"; diff --git a/spikes/c1/S14_attach_side_effects.php b/spikes/c1/S14_attach_side_effects.php new file mode 100644 index 0000000..51bf1b4 --- /dev/null +++ b/spikes/c1/S14_attach_side_effects.php @@ -0,0 +1,295 @@ +handle. + * Every process needs its OWN handle (its object store is request/process memory), + * but they all write the same shared field. Last writer wins; every other process + * is left with an obj->handle that names a slot in ITS store belonging to a + * different object — and spl_object_id(), object comparison, the shutdown pass and + * ObjectStore::recycle() all read that field. + * + * B obj->properties is a LAZY, request-heap HashTable* that the engine materializes + * the first time anything asks for the property bag by name (get_object_vars(), + * var_dump(), json_encode(), (array) cast, ...). Written into a shared struct, the + * pointer is meaningless — and actively dangerous — in every other process. + * + * Run: php -d ffi.enable=1 -d opcache.jit=off S14_attach_side_effects.php + */ + +require __DIR__ . '/lib/bootstrap.php'; + +use ZEngine\Core; +use ZEngine\Reflection\ReflectionClass as ZReflectionClass; +use ZEngine\Reflection\ReflectionValue; +use ZEngine\Type\PersistentObjectFactory; + +spike_header('S14', 'per-process side effects of attach'); + +if (!Core::isInitialized()) { + spike_result('S14 skipped', false, 'z-engine is unavailable on this PHP minor'); + exit(0); +} + +const ARENA_SIZE = 1 << 20; +const OFF_MUTEX = 0; +const OFF_BAR = 128; // spin barrier +const OFF_REPORT = 256; +const OFF_OBJ = 4096; + +final class S14Holder +{ + public int $alpha = 1; + + public string $beta = 'b'; +} + +$arena = spike_mmap_shared(ARENA_SIZE); +$mutex = spike_mutex_init($arena + OFF_MUTEX); +$bar = spike_at('int64_t', $arena + OFF_BAR); +$report = spike_at('int64_t', $arena + OFF_REPORT); + +// --- place an engine-formatted object in the arena --------------------------- +$src = new S14Holder(); +$rv = new ReflectionValue($src); +$rawSrc = $rv->getRawObject(); +$size = ZReflectionClass::getObjectSize($rawSrc->ce); +$clone = PersistentObjectFactory::persistentClone($rawSrc); +$rv->release(); + +libc()->memcpy(spike_at('char', $arena + OFF_OBJ), spike_at('char', Core::addressOf($clone)), $size); +$shared = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $arena + OFF_OBJ); + +printf("shared zend_object at 0x%x, %d bytes; handle field currently %d, properties=0x%x\n\n", + $arena + OFF_OBJ, $size, $shared->handle, + $shared->properties === null ? 0 : Core::addressOf($shared->properties)); + +// =========================================================================== +// A — concurrent ObjectStore::put on the SAME shared struct +// =========================================================================== +spike_step('A — parent attaches, then two children attach the same object simultaneously'); + +$parentHandle = Core::$executor->objectStore->put($shared); +$parentValue = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); +$parentValue->getNativeValue($parentInstance); +printf(" parent put() -> handle %d, obj->handle=%d, spl_object_id=%d\n", + $parentHandle, $shared->handle, spl_object_id($parentInstance)); + +$pids = spike_fork(2, function (int $role) use ($shared, $bar, $report): int { + // Rendezvous so both put() calls really overlap. + $bar[0] = $bar[0] + 1; + while ($bar[0] < 2) { + // spin + } + + $handle = Core::$executor->objectStore->put($shared); + $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); + $value->getNativeValue($instance); + + $report[10 + $role * 4 + 0] = $handle; // handle the engine gave THIS process + $report[10 + $role * 4 + 1] = spl_object_id($instance); + usleep(20000); // let the sibling clobber the field + $report[10 + $role * 4 + 2] = $shared->handle; // what the shared field says afterwards + $report[10 + $role * 4 + 3] = spl_object_id($instance); // ...and what spl_object_id says now + + return 0; +}); +$waits = spike_wait($pids); +printf(" children: %s\n", spike_describe_wait($waits)); + +for ($r = 0; $r < 2; $r++) { + printf(" child %d: put() returned handle %d, spl_object_id right after = %d; ". + "20 ms later obj->handle=%d and spl_object_id=%d\n", + $r, $report[10 + $r * 4], $report[10 + $r * 4 + 1], $report[10 + $r * 4 + 2], $report[10 + $r * 4 + 3]); +} +printf(" parent afterwards: obj->handle=%d, spl_object_id(\$parentInstance)=%d (parent's real slot is %d)\n", + $shared->handle, spl_object_id($parentInstance), $parentHandle); + +spike_result('A obj->handle is a SHARED field every attaching process overwrites', + $shared->handle !== $parentHandle, + sprintf('parent attached at slot %d, shared field now says %d', $parentHandle, $shared->handle)); +spike_result('A spl_object_id() in the parent is now WRONG', + spl_object_id($parentInstance) !== $parentHandle, + 'spl_object_id() reads obj->handle directly — it returns a foreign process\'s slot number'); + +// What sits in the parent's own store at the clobbered handle? +$store = Core::$executor->objectStore; +$victim = $store[$shared->handle] ?? null; +printf(" parent's object store slot %d currently holds: %s\n", + $shared->handle, + $victim === null ? 'nothing / invalid bucket' : 'a DIFFERENT live object (' . get_class($victim->getNativeValue()) . ')'); +spike_note('recycle()/detach() at request end would therefore return a FOREIGN slot to the free list'); + +echo "\n"; + +// =========================================================================== +// B — the dynamic-properties pointer hazard +// =========================================================================== +spike_step('B — obj->properties: the lazy request-heap pointer written into a shared struct'); + +printf(" before: obj->properties = 0x%x\n", $shared->properties === null ? 0 : Core::addressOf($shared->properties)); + +// B1: child A merely asks for the property bag by name. +$pids = spike_fork(1, function () use ($shared, $report): int { + $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); + $value->getNativeValue($instance); + + $report[30] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); + $vars = get_object_vars($instance); // the trigger + $report[31] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); + $report[32] = count($vars); + + ob_start(); + var_dump($instance); // second common trigger + ob_end_clean(); + $report[33] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); + + $enc = json_encode($instance); + $report[34] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); + $report[35] = strlen((string) $enc); + + $cast = (array) $instance; + $report[36] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); + $report[37] = count($cast); + + return 0; +}); +$waits = spike_wait($pids); +printf(" trigger child: %s\n", spike_describe_wait($waits)); +printf(" inside child A: properties 0x%x -> get_object_vars(%d vars) -> 0x%x -> var_dump -> 0x%x -> json_encode(%d bytes) -> 0x%x -> (array) cast(%d) -> 0x%x\n", + $report[30], $report[32], $report[31], $report[33], $report[35], $report[34], $report[37], $report[36]); + +$propsAfter = $shared->properties === null ? 0 : Core::addressOf($shared->properties); +printf(" PARENT now reads obj->properties = 0x%x (child A is gone; that is child A's private heap)\n", $propsAfter); + +$leaked = $propsAfter !== 0; +spike_result('B a read-only-looking call writes a request-heap pointer into the SHARED struct', + true, + $leaked + ? 'CONFIRMED: obj->properties is non-NULL in the shared struct after a child called get_object_vars()/var_dump()' + : 'NOT reproduced on this build: obj->properties stayed NULL (see note below)'); + +if ($leaked) { + // B2: a sibling that now touches the property bag follows the dangling pointer. + spike_step('B2 — sibling child B follows the inherited obj->properties pointer'); + $pids = spike_fork(1, function () use ($shared, $report): int { + $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); + $value->getNativeValue($instance); + + $report[40] = 1; // reached the child + $vars = get_object_vars($instance); + $report[41] = count($vars); + $report[42] = 1; + ob_start(); + var_dump($instance); + $dump = (string) ob_get_clean(); + $report[43] = strlen($dump); + $report[44] = 1; + + return 0; + }); + $waits = spike_wait($pids); + printf(" sibling child: %s\n", spike_describe_wait($waits)); + printf(" progress markers: reached=%d, get_object_vars returned %d vars (done=%d), var_dump produced %d bytes (done=%d)\n", + $report[40], $report[41], $report[42], $report[43], $report[44]); + + $crashed = $waits[array_key_first($waits)]['signal'] !== null; + spike_result('B2 sibling outcome', + true, + $crashed + ? 'CRASHED (signal ' . $waits[array_key_first($waits)]['signal'] . ') following the foreign properties pointer' + : sprintf('survived but read %d "properties" out of a heap block it never wrote — silent garbage', $report[41])); +} + +// B3: the sibling above only survived because fork() gave it the SAME copy-on-write heap +// layout as the writer, so the address happened to be mapped. A process that did not fork +// from the writer (a worker started later, a different pool member) has nothing there. +// Simulate that by pointing obj->properties at an address that is mapped in nobody. +spike_step('B3 — what a process that did NOT inherit the writer\'s heap sees'); + +$unmapped = $arena + (ARENA_SIZE * 64); // far past the arena: never mapped +$shared->properties = Core::pointerAtAddress(\ZEngine\Generated\HashTable::class, $unmapped); +printf(" obj->properties forced to 0x%x (unmapped in every process)\n", $unmapped); + +$pids = spike_fork(1, function () use ($shared, $report): int { + $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); + $value->getNativeValue($instance); + $report[50] = 1; + $vars = get_object_vars($instance); + $report[51] = count($vars); + + return 0; +}); +$waits = spike_wait($pids); +$w = $waits[array_key_first($waits)]; +$signal = $w['signal']; +$died = $signal !== null || $w['exit'] !== 0; +printf(" child: %s (reached=%d, returned %d vars)\n", spike_describe_wait($waits), $report[50], $report[51]); +spike_result('B3 dereferencing a foreign obj->properties kills the process', $died, + $signal !== null + ? 'SIGNAL ' . $signal . ' (' . spike_signame($signal) . ') — hard crash' + : ($died + ? 'engine bailed out with a fatal error (exit ' . $w['exit'] . ') after reading a garbage nTableSize' + : 'no fault observed on this run')); + +$shared->properties = null; // put the struct back into a sane state + +spike_note('the same field is also written by: property_exists on dynamic props, iteration over the object,'); +spike_note('serialize(), debug_zval_dump(), Reflection*::getProperties() and every (array)/json path.'); + +// =========================================================================== +// C — the third per-process field: obj->ce +// =========================================================================== +echo "\n"; +spike_step('C — obj->ce and obj->handlers: which of them is really fork-stable?'); + +$handlersAddr = Core::addressOf(Core::addr(Core::getStandardObjectHandlers())); +$ceAddr = Core::addressOf($shared->ce); +printf(" parent: std_object_handlers=0x%x, S14Holder ce=0x%x\n", $handlersAddr, $ceAddr); + +$pids = spike_fork(2, function (int $role) use ($shared, $report): int { + $report[60 + $role * 4 + 0] = Core::addressOf(Core::addr(Core::getStandardObjectHandlers())); + $report[60 + $role * 4 + 1] = Core::addressOf($shared->ce); + + // A class DEFINED AFTER the fork: its class entry comes out of this process's own + // compiler arena. Child 0 declares decoy classes first, which is all it takes for + // the two children to place "the same" class at different addresses — the realistic + // case being two workers that autoload different things in a different order. + if ($role === 0) { + for ($i = 0; $i < 40; $i++) { + eval("class S14Decoy{$i} { public int \$a = 1; public string \$b = 'x'; }"); + } + } + eval('class S14LateClass { public int $v = 1; }'); + $lateValue = Core::$executor->classTable->find('s14lateclass'); + $report[60 + $role * 4 + 2] = $lateValue === null ? 0 : Core::addressOf($lateValue->getRawClass()); + + return 0; +}); +spike_wait($pids); + +printf(" child 0: handlers=0x%x S14Holder ce=0x%x post-fork S14LateClass ce=0x%x\n", + $report[60], $report[61], $report[62]); +printf(" child 1: handlers=0x%x S14Holder ce=0x%x post-fork S14LateClass ce=0x%x\n", + $report[64], $report[65], $report[66]); + +spike_result('C std_object_handlers is address-identical in every forked process', + $report[60] === $handlersAddr && $report[64] === $handlersAddr, + 'safe to keep INSIDE the shared struct'); +spike_result('C a PRE-fork class entry is address-identical too', + $report[61] === $ceAddr && $report[65] === $ceAddr, + 'obj->ce happens to agree — but only because the class was loaded before the fork'); +spike_result('C a POST-fork class entry differs per process', + $report[62] !== $report[66] && $report[62] !== 0 && $report[66] !== 0, + sprintf('0x%x vs 0x%x — obj->ce cannot be a shared field once classes are autoloaded lazily', + $report[62], $report[66])); + +echo "\nDone.\n"; diff --git a/spikes/c1/S16_string_swap.php b/spikes/c1/S16_string_swap.php new file mode 100644 index 0000000..5e6eefd --- /dev/null +++ b/spikes/c1/S16_string_swap.php @@ -0,0 +1,279 @@ +getRawValue(); + $bytes = 24 + $entry->getLength() + 1; // gc + h + len + val[len] + NUL + libc()->memcpy(spike_at('char', $arena + $offset), spike_at('char', Core::addressOf($raw)), $bytes); + + return [$arena + $offset, $bytes, $entry]; +}; + +[$addrA, $sizeA, $entryA] = $internIntoArena('alpha-alpha-alpha-alpha', OFF_STR_A); +[$addrB, $sizeB, $entryB] = $internIntoArena('BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO', OFF_STR_B); + +$viewA = StringEntry::fromCData(Core::pointerAtAddress(\ZEngine\Generated\zend_string::class, $addrA)); +$viewB = StringEntry::fromCData(Core::pointerAtAddress(\ZEngine\Generated\zend_string::class, $addrB)); + +printf(" A at 0x%x (%d bytes) len=%d interned=%s value=%s\n", + $addrA, $sizeA, $viewA->getLength(), var_export($viewA->isInterned(), true), $viewA->getStringValue()); +printf(" B at 0x%x (%d bytes) len=%d interned=%s value=%s\n", + $addrB, $sizeB, $viewB->getLength(), var_export($viewB->isInterned(), true), $viewB->getStringValue()); +spike_result('A both strings readable from the arena', + $viewA->getStringValue() === 'alpha-alpha-alpha-alpha' && $viewB->getStringValue() === 'BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO'); + +// =========================================================================== +// A2 — a shared object whose string property points at arena string A +// =========================================================================== +final class S16Holder +{ + public string $name = 'initial'; + + public int $seq = 0; +} + +$src = new S16Holder(); +$rv = new ReflectionValue($src); +$raw = $rv->getRawObject(); +$size = ZReflectionClass::getObjectSize($raw->ce); +$clone = PersistentObjectFactory::persistentClone($raw); +$rv->release(); + +libc()->memcpy(spike_at('char', $arena + OFF_OBJ), spike_at('char', Core::addressOf($clone)), $size); +$sharedObj = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $arena + OFF_OBJ); +Core::$executor->objectStore->put($sharedObj); +$objValue = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $sharedObj[0]); +$objValue->getNativeValue($shared); + +// properties_table[0] is $name (declaration order). Point it at arena string A, +// non-refcounted IS_STRING (== 6) because the payload is GC_IMMUTABLE. +$slotAddr = $arena + OFF_OBJ + FFI::sizeof(Core::new('zend_object')) - FFI::sizeof(Core::new('zval')); +$slotPtr = spike_at('uint64_t', $slotAddr); +$slotType = spike_at('uint32_t', $slotAddr + 8); +$slotPtr[0] = $addrA; +$slotType[0] = 6; + +printf(" \$name slot zval at 0x%x (value word 8-byte aligned: %s)\n", + $slotAddr, var_export($slotAddr % 8 === 0, true)); +spike_result('A2 property reads through the arena string', $shared->name === 'alpha-alpha-alpha-alpha', + var_export($shared->name, true)); + +// =========================================================================== +// B/C — swap the pointer under a mutex and without one +// =========================================================================== +echo "\n"; +$ROUNDS = 300000; +spike_step(sprintf('B/C — %d pointer swaps by child 0; child 1 reads LOCKED, child 2 reads UNLOCKED', $ROUNDS)); + +$t0 = microtime(true); +$pids = spike_fork(3, function (int $role) use ($arena, $shared, $report, $slotAddr, $addrA, $addrB, $ROUNDS): int { + $ffi = libc(); + $mutex = spike_mutex_at($arena + OFF_MUTEX); + $slot = spike_at('uint64_t', $slotAddr); + $stamp = spike_at('uint64_t', $arena + OFF_REPORT + 8 * 60); + + if ($role === 0) { // swapper + for ($i = 1; $i <= $ROUNDS; $i++) { + $ffi->pthread_mutex_lock($mutex); + $slot[0] = ($i & 1) === 1 ? $addrB : $addrA; + $stamp[0] = hrtime(true); + $ffi->pthread_mutex_unlock($mutex); + } + $report[0] = 1; + + return 0; + } + + if ($role === 1) { // locked reader + $reads = 0; + $torn = 0; + $sawA = 0; + $sawB = 0; + $maxLag = 0; + while ($report[0] === 0) { + $ffi->pthread_mutex_lock($mutex); + $p = $slot[0]; + $name = $shared->name; // full PHP-level string read + $ts = $stamp[0]; + $ffi->pthread_mutex_unlock($mutex); + $reads++; + if ($p === $addrA) { + $sawA++; + if ($name !== 'alpha-alpha-alpha-alpha') { + $torn++; + } + } elseif ($p === $addrB) { + $sawB++; + if ($name !== 'BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO') { + $torn++; + } + } else { + $torn++; // a value that is neither: TORN + } + $lag = hrtime(true) - $ts; + if ($ts > 0 && $lag > $maxLag) { + $maxLag = $lag; + } + } + $report[1] = $reads; + $report[2] = $torn; + $report[3] = $sawA; + $report[4] = $sawB; + $report[5] = $maxLag; + + return 0; + } + + // unlocked reader: no mutex at all + $reads = 0; + $torn = 0; + $bad = 0; + $maxLag = 0; + while ($report[0] === 0) { + $p = $slot[0]; + $name = $shared->name; + $ts = $stamp[0]; + $reads++; + if ($p !== $addrA && $p !== $addrB) { + $torn++; // a torn 8-byte pointer read + } + if ($name !== 'alpha-alpha-alpha-alpha' && $name !== 'BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO') { + $bad++; // a string that is neither + } + $lag = hrtime(true) - $ts; + if ($ts > 0 && $lag > $maxLag) { + $maxLag = $lag; + } + } + $report[6] = $reads; + $report[7] = $torn; + $report[8] = $bad; + $report[9] = $maxLag; + + return 0; +}); +$waits = spike_wait($pids); +$dt = microtime(true) - $t0; + +printf(" children: %s (%.2f s)\n", spike_describe_wait($waits), $dt); +printf(" LOCKED reader: %d reads (A=%d B=%d), %d torn, max staleness %.1f us\n", + $report[1], $report[3], $report[4], $report[2], $report[5] / 1000); +printf(" UNLOCKED reader: %d reads, %d torn pointers, %d unexpected string values, max staleness %.1f us\n", + $report[6], $report[7], $report[8], $report[9] / 1000); + +spike_result('B locked pointer swap: never torn, both values observed', + $report[2] === 0 && $report[3] > 0 && $report[4] > 0); +spike_result('C UNLOCKED aligned 8-byte pointer swap: never torn either', + $report[7] === 0 && $report[8] === 0, + 'aligned 8-byte loads/stores are atomic on x86-64 — the lock buys ORDERING between slots, not per-pointer atomicity'); +printf(" parent reads \$shared->name = %s\n", var_export($shared->name, true)); + +// =========================================================================== +// D — control: the same swap on a MISALIGNED slot that straddles a cache line +// =========================================================================== +echo "\n"; +spike_step('D — control: the same 8-byte swap on a slot straddling a 4 KiB page boundary'); + +$straddleAddr = $arena + OFF_STRADD; +printf(" slot at 0x%x: offset %% 4096 = %d, offset %% 64 = %d — the 8 bytes span two pages\n", + $straddleAddr, $straddleAddr % 4096, $straddleAddr % 64); + +$D_ROUNDS = 3000000; +$pids = spike_fork(2, function (int $role) use ($arena, $report, $straddleAddr, $addrA, $addrB, $D_ROUNDS): int { + $slot = spike_at('uint64_t', $straddleAddr); + + if ($role === 0) { + for ($i = 1; $i <= $D_ROUNDS; $i++) { + $slot[0] = ($i & 1) === 1 ? $addrB : $addrA; + } + $report[20] = 1; + + return 0; + } + + $reads = 0; + $torn = 0; + while ($report[20] === 0) { + $p = $slot[0]; + $reads++; + if ($p !== 0 && $p !== $addrA && $p !== $addrB) { + $torn++; + } + } + $report[21] = $reads; + $report[22] = $torn; + + return 0; +}); +$waits = spike_wait($pids); +printf(" children: %s\n", spike_describe_wait($waits)); +printf(" misaligned unlocked reader: %d reads, %d TORN values\n", $report[21], $report[22]); +spike_result('D misaligned (page-straddling) unlocked reads', true, + $report[22] > 0 + ? sprintf('TORE %d times: the atomicity guarantee is alignment-dependent, arena zvals must stay 8-byte aligned', $report[22]) + : 'no tearing observed on this CPU, but the ISA gives no guarantee for a misaligned access — keep the alignment invariant'); + +echo "\nDone.\n"; diff --git a/spikes/c1/S17_closures_across_fork.php b/spikes/c1/S17_closures_across_fork.php new file mode 100644 index 0000000..5777678 --- /dev/null +++ b/spikes/c1/S17_closures_across_fork.php @@ -0,0 +1,279 @@ +getRawObject(); + $ptr = Core::cast(zend_closure::class, $raw); + $value->release(); + + return $ptr; +} + +// =========================================================================== +// (a) closures compiled BEFORE fork +// =========================================================================== +spike_step('(a) closures compiled PRE-fork, invoked concurrently by two children'); + +$base = 1000; +$factor = 7; + +$staticClosure = static function (int $x): int { // no $this at all + return $x * 3 + 1; +}; +$useClosure = function (int $x) use ($base, $factor): int { // captured scalars + return $base + $x * $factor; +}; + +final class S17Scope +{ + public int $offset = 5; + + public function make(): \Closure + { + return function (int $x): int { + return $x + $this->offset; + }; + } +} +$boundClosure = (new S17Scope())->make(); // bound $this + +$INVOKES = 100000; + +foreach (['static' => $staticClosure, 'use' => $useClosure, 'bound' => $boundClosure] as $label => $c) { + $p = closurePointer($c); + printf(" %-6s closure: zend_closure at 0x%x, handle %d, fn_flags=0x%x (HEAP_RT_CACHE=%s), op_array.opcodes=0x%x\n", + $label, + Core::addressOf($p), + $p->std->handle, + $p->func->common->fn_flags, + var_export(($p->func->common->fn_flags & Core::ZEND_ACC_HEAP_RT_CACHE) !== 0, true), + Core::addressOf($p->func->op_array->opcodes)); +} + +$t0 = microtime(true); +$pids = spike_fork(2, function (int $role) use ($staticClosure, $useClosure, $boundClosure, $report, $INVOKES): int { + $bad = 0; + $acc = 0; + for ($i = 0; $i < $INVOKES; $i++) { + $a = $staticClosure($i); + $b = $useClosure($i); + $c = $boundClosure($i); + if ($a !== $i * 3 + 1 || $b !== 1000 + $i * 7 || $c !== $i + 5) { + $bad++; + } + $acc += $a + $b + $c; + } + $report[$role * 4 + 0] = $bad; + $report[$role * 4 + 1] = $acc; + $report[$role * 4 + 2] = spl_object_id($staticClosure); + + return 0; +}); +$waits = spike_wait($pids); +$dt = microtime(true) - $t0; + +printf(" children: %s (%.2f s, %d invocations each of 3 closures)\n", + spike_describe_wait($waits), $dt, $INVOKES); +printf(" child 0: %d wrong results, checksum %d, closure spl_object_id %d\n", $report[0], $report[1], $report[2]); +printf(" child 1: %d wrong results, checksum %d, closure spl_object_id %d\n", $report[4], $report[5], $report[6]); + +spike_result('(a) pre-fork closures invoke correctly and identically in both children', + $report[0] === 0 && $report[4] === 0 && $report[1] === $report[5] && $report[1] > 0, + 'op_array, literals and the captured statics are all COW-shared read-only data'); +spike_note('run_time_cache is per-closure heap memory (ZEND_ACC_HEAP_RT_CACHE): each child COW-copies its own'); + +// =========================================================================== +// (b) a closure created AFTER fork, invoked in a sibling +// =========================================================================== +echo "\n"; +spike_step('(b) closure created POST-fork in child A, its address handed to child B over a pipe'); + +[$parentEnd, $childEnd] = spike_pipe(); + +$pidA = pcntl_fork(); +if ($pidA === 0) { + fclose($parentEnd); + // Push the heap forward so the new closure does NOT land on a page the parent + // already has: this is exactly the COW divergence a post-fork allocation causes. + $ballast = []; + for ($i = 0; $i < 20000; $i++) { + $ballast[] = str_repeat('x', 64) . $i; + } + $magic = 987654321; + $late = static function (int $x) use ($magic): int { + return $x + $magic; + }; + $ptr = closurePointer($late); + fwrite($childEnd, pack('J', Core::addressOf($ptr))); + fwrite($childEnd, pack('J', $late(1))); + fflush($childEnd); + usleep(200000); // stay alive briefly, then die WITH its heap + spike_hard_exit(0); +} +fclose($childEnd); +$payload = fread($parentEnd, 16); +$vals = unpack('Jaddr/Jresult', (string) $payload); +$lateAddr = $vals['addr']; +printf(" child A built the closure at 0x%x; in child A it returns %d for input 1\n", $lateAddr, $vals['result']); +pcntl_waitpid($pidA, $st); + +// Child B: a SIBLING of A that never saw A's allocation. +$pidB = pcntl_fork(); +if ($pidB === 0) { + $report[20] = 1; // reached + $raw = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $lateAddr); + $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $raw[0]); + $value->getNativeValue($alien); + $report[21] = 1; // materialized a PHP value at that address + $report[22] = is_object($alien) ? 1 : 0; + $report[23] = $alien instanceof \Closure ? 1 : 0; + if ($alien instanceof \Closure) { + $report[24] = 1; // about to invoke + $r = $alien(1); + $report[25] = 1; // survived the invoke + $report[26] = is_int($r) ? $r : -1; + } + spike_hard_exit(0); +} +$waits = spike_wait([$pidB]); +$w = $waits[$pidB]; +printf(" child B: %s\n", spike_describe_wait($waits)); +printf(" markers: reached=%d materialized=%d is_object=%d is_Closure=%d invoked=%d survived=%d result=%d\n", + $report[20], $report[21], $report[22], $report[23], $report[24], $report[25], $report[26]); + +$divergent = $w['signal'] !== null || $w['exit'] !== 0 || $report[23] !== 1 || $report[26] !== 987654322; +spike_result('(b) invoking a sibling-built closure by address is UNSAFE', $divergent, + $w['signal'] !== null + ? 'child B died with signal ' . $w['signal'] . ' (' . spike_signame($w['signal']) . ')' + : ($report[23] !== 1 + ? 'the address did not even hold a Closure in child B — COW divergence' + : ($report[26] !== 987654322 + ? 'child B invoked it and got ' . $report[26] . ' instead of 987654322' + : 'child B happened to agree — the heap had not diverged at that address (rerun)'))); +spike_note('every post-fork allocation lands on a private COW page; addresses are only meaningful'); +spike_note('inside the process that allocated them. Closures therefore cannot be shared by address.'); + +// =========================================================================== +// (c) pointer inventory of a zend_closure +// =========================================================================== +echo "\n"; +spike_step('(c) every pointer a zend_closure carries (feasibility of arena-cloning)'); + +$probe = function (int $x) use ($base): int { + static $calls = 0; + $calls++; + + return $x + $base + $calls; +}; +$probe(1); // materialize static_variables_ptr + +$p = closurePointer($probe); +$oa = $p->func->op_array; + +$fields = [ + 'zend_closure (whole struct)' => Core::addressOf($p), + 'std.ce (Closure class entry)' => $p->std->ce === null ? 0 : Core::addressOf($p->std->ce), + 'std.handlers' => $p->std->handlers === null ? 0 : Core::addressOf($p->std->handlers), + 'func.op_array.function_name' => $oa->function_name === null ? 0 : Core::addressOf($oa->function_name), + 'func.op_array.scope' => $oa->scope === null ? 0 : Core::addressOf($oa->scope), + 'func.op_array.arg_info' => $oa->arg_info === null ? 0 : Core::addressOf($oa->arg_info), + 'func.op_array.attributes' => $oa->attributes === null ? 0 : Core::addressOf($oa->attributes), + 'func.op_array.run_time_cache__ptr' => $oa->run_time_cache__ptr === null ? 0 : Core::addressOf($oa->run_time_cache__ptr), + 'func.op_array.opcodes' => $oa->opcodes === null ? 0 : Core::addressOf($oa->opcodes), + 'func.op_array.static_variables' => $oa->static_variables === null ? 0 : Core::addressOf($oa->static_variables), + 'func.op_array.static_variables_ptr__ptr' => $oa->static_variables_ptr__ptr === null ? 0 : Core::addressOf($oa->static_variables_ptr__ptr), + 'func.op_array.vars' => $oa->vars === null ? 0 : Core::addressOf($oa->vars), + 'func.op_array.refcount' => $oa->refcount === null ? 0 : Core::addressOf($oa->refcount), + 'func.op_array.literals' => $oa->literals === null ? 0 : Core::addressOf($oa->literals), + 'func.op_array.filename' => $oa->filename === null ? 0 : Core::addressOf($oa->filename), + 'func.op_array.dynamic_func_defs' => $oa->dynamic_func_defs === null ? 0 : Core::addressOf($oa->dynamic_func_defs), + 'func.op_array.live_range' => $oa->live_range === null ? 0 : Core::addressOf($oa->live_range), + 'func.op_array.try_catch_array' => $oa->try_catch_array === null ? 0 : Core::addressOf($oa->try_catch_array), + 'this_ptr.value' => $p->this_ptr->value->lval, + 'called_scope' => $p->called_scope === null ? 0 : Core::addressOf($p->called_scope), +]; + +printf(" %-42s %-18s %s\n", 'field', 'address', 'notes'); +foreach ($fields as $name => $addr) { + printf(" %-42s 0x%-16x %s\n", $name, $addr, $addr === 0 ? '(null)' : ''); +} +printf(" counts: num_args=%d last_var=%d T=%d last(opcodes)=%d last_literal=%d cache_size=%d num_dynamic_func_defs=%d\n", + $oa->num_args, $oa->last_var, $oa->T, $oa->last, $oa->last_literal, $oa->cache_size, $oa->num_dynamic_func_defs); +printf(" byte cost of a deep clone: opcodes %d*%d=%d, literals %d*%d=%d, vars %d*8=%d, arg_info %d*%d=%d\n", + $oa->last, FFI::sizeof(Core::new('zend_op')), $oa->last * FFI::sizeof(Core::new('zend_op')), + $oa->last_literal, FFI::sizeof(Core::new('zval')), $oa->last_literal * FFI::sizeof(Core::new('zval')), + $oa->last_var, $oa->last_var * 8, + $oa->num_args, FFI::sizeof(Core::new('zend_arg_info')), $oa->num_args * FFI::sizeof(Core::new('zend_arg_info'))); + +$nonNull = count(array_filter($fields, static fn (int $a): bool => $a !== 0)); +spike_result('(c) pointer inventory taken', true, + sprintf('%d of %d zend_closure/op_array pointer fields are non-NULL for a trivial closure', $nonNull, count($fields))); + +// Are the op_array pointers stable across fork? (They must be, for (a) to work.) +$pids = spike_fork(1, function () use ($probe, $report): int { + $q = closurePointer($probe); + $qoa = $q->func->op_array; + $report[30] = Core::addressOf($q); + $report[31] = $qoa->opcodes === null ? 0 : Core::addressOf($qoa->opcodes); + $report[32] = $qoa->literals === null ? 0 : Core::addressOf($qoa->literals); + $report[33] = $qoa->static_variables === null ? 0 : Core::addressOf($qoa->static_variables); + $report[34] = $q->std->handle; + + return 0; +}); +spike_wait($pids); +printf(" child sees the SAME closure at 0x%x (opcodes 0x%x, literals 0x%x, static_variables 0x%x, handle %d)\n", + $report[30], $report[31], $report[32], $report[33], $report[34]); +spike_result('(c) a pre-fork closure keeps identical addresses in the child', + $report[30] === Core::addressOf($p) && $report[31] === ($oa->opcodes === null ? 0 : Core::addressOf($oa->opcodes))); + +spike_note('run_time_cache__ptr and static_variables_ptr__ptr point into the REQUEST arena, not into'); +spike_note('the compiled op_array: an arena-resident closure would share those per-request slots'); +spike_note('between processes. Any closure design must re-mint them per process.'); + +echo "\nDone.\n"; diff --git a/spikes/c1/lib/bootstrap.php b/spikes/c1/lib/bootstrap.php new file mode 100644 index 0000000..9610899 --- /dev/null +++ b/spikes/c1/lib/bootstrap.php @@ -0,0 +1,348 @@ += 80500 + ? [SPIKE_ROOT . '/zengine-85'] // master == 8.5.x-dev + : ['/home/user/z-engine']; // 8.4 branch == 8.4.x-dev + + foreach ($candidates as $dir) { + if (is_dir($dir . '/src') && is_dir($dir . '/include/' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION)) { + return $dir; + } + } + + return null; +} + +spl_autoload_register(static function (string $class): void { + static $prefixes = null; + if ($prefixes === null) { + $prefixes = []; + $ze = spike_zengine_dir(); + if ($ze !== null) { + $prefixes['ZEngine\\'] = $ze . '/src/'; + } + $prefixes['Lisachenko\\SharedData\\'] = '/home/user/php-shared-data-extension/src/'; + } + + foreach ($prefixes as $prefix => $baseDir) { + if (!str_starts_with($class, $prefix)) { + continue; + } + $file = $baseDir . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; + if (is_file($file)) { + require $file; + } + } +}); + +/** Boots z-engine's Core, returns null on success or the failure reason. */ +function spike_boot_zengine(): ?string +{ + $dir = spike_zengine_dir(); + if ($dir === null) { + return sprintf('no z-engine line available for PHP %s in this sandbox', PHP_VERSION); + } + try { + \ZEngine\Core::init(); + } catch (\Throwable $e) { + return get_class($e) . ': ' . $e->getMessage(); + } + + return null; +} + +// --------------------------------------------------------------------------- +// libc binding: mmap + robust pshared mutexes +// --------------------------------------------------------------------------- + +const PROT_READ = 1; +const PROT_WRITE = 2; +const MAP_SHARED = 0x01; +const MAP_PRIVATE = 0x02; +const MAP_ANONYMOUS = 0x20; // Linux x86-64 + +// glibc / Linux x86-64 constants +const PTHREAD_PROCESS_SHARED = 1; +const PTHREAD_MUTEX_ROBUST = 1; +const EOWNERDEAD = 130; +const ENOTRECOVERABLE = 131; +const EBUSY = 16; + +function libc(): FFI +{ + static $ffi = null; + if ($ffi !== null) { + return $ffi; + } + + // glibc x86-64: pthread_mutex_t is 40 bytes, pthread_mutexattr_t is 4. + // We over-size the opaque blobs to 64/8 bytes so a slot is cache-line sized. + $ffi = FFI::cdef(<<<'C' + typedef struct { char __opaque[64]; } spike_mutex_t; + typedef struct { char __opaque[8]; } spike_mutexattr_t; + + // NOTE: mmap is declared returning char* on purpose. FFI::cast('uintptr_t', $p) + // on a `void *` CData yields 0 in PHP 8.4/8.5 (void* is special-cased and the + // cast reinterprets the *pointee*); on any typed pointer it yields the address. + char *mmap(void *addr, size_t length, int prot, int flags, int fd, long offset); + int munmap(char *addr, size_t length); + int mprotect(char *addr, size_t len, int prot); + + int pthread_mutexattr_init(spike_mutexattr_t *attr); + int pthread_mutexattr_setpshared(spike_mutexattr_t *attr, int pshared); + int pthread_mutexattr_setrobust(spike_mutexattr_t *attr, int robust); + int pthread_mutexattr_settype(spike_mutexattr_t *attr, int type); + int pthread_mutex_init(spike_mutex_t *mutex, const spike_mutexattr_t *attr); + int pthread_mutex_lock(spike_mutex_t *mutex); + int pthread_mutex_trylock(spike_mutex_t *mutex); + int pthread_mutex_unlock(spike_mutex_t *mutex); + int pthread_mutex_consistent(spike_mutex_t *mutex); + int pthread_mutex_destroy(spike_mutex_t *mutex); + + char *memcpy(char *dest, const char *src, size_t n); + char *memset(char *s, int c, size_t n); + int memcmp(const char *a, const char *b, size_t n); + int getpid(void); + void _exit(int status); + unsigned int sleep(unsigned int seconds); + C, null); + + return $ffi; +} + +/** + * Anonymous shared mapping. Returns [void* cdata, size]. + */ +/** @return int base address of a fresh zero-filled MAP_SHARED|MAP_ANONYMOUS region */ +function spike_mmap_shared(int $size): int +{ + return spike_mmap($size, MAP_SHARED | MAP_ANONYMOUS); +} + +/** @return int base address of a fresh zero-filled MAP_PRIVATE|MAP_ANONYMOUS region */ +function spike_mmap_private(int $size): int +{ + return spike_mmap($size, MAP_PRIVATE | MAP_ANONYMOUS); +} + +function spike_mmap(int $size, int $flags): int +{ + $ptr = libc()->mmap(null, $size, PROT_READ | PROT_WRITE, $flags, -1, 0); + $addr = spike_addr($ptr); + if ($addr === 0 || $addr === -1) { + throw new RuntimeException(sprintf('mmap(size=%d, flags=0x%x) failed', $size, $flags)); + } + libc()->memset($ptr, 0, $size); + + return $addr; +} + +function spike_addr(object $p): int +{ + return (int) FFI::cast('uintptr_t', $p)->cdata; +} + +/** Materializes a typed pointer at a raw address (allocation-free view, via libc binding). */ +function spike_at(string $type, int $address): FFI\CData +{ + return libc()->cast($type . '*', $address); +} + +/** + * Initializes a process-shared (optionally robust) mutex at $address inside a shared mapping. + */ +function spike_mutex_init(int $address, bool $robust = false): FFI\CData +{ + $ffi = libc(); + $attr = $ffi->new('spike_mutexattr_t'); + $rc = $ffi->pthread_mutexattr_init(FFI::addr($attr)); + $rc |= $ffi->pthread_mutexattr_setpshared(FFI::addr($attr), PTHREAD_PROCESS_SHARED); + if ($robust) { + $rc |= $ffi->pthread_mutexattr_setrobust(FFI::addr($attr), PTHREAD_MUTEX_ROBUST); + } + if ($rc !== 0) { + throw new RuntimeException('pthread_mutexattr_* failed'); + } + + $mutex = $ffi->cast('spike_mutex_t*', $address); + $rc = $ffi->pthread_mutex_init($mutex, FFI::addr($attr)); + if ($rc !== 0) { + throw new RuntimeException("pthread_mutex_init failed: {$rc}"); + } + + return $mutex; +} + +function spike_mutex_at(int $address): FFI\CData +{ + return libc()->cast('spike_mutex_t*', $address); +} + +function spike_lock(FFI\CData $m): int +{ + return libc()->pthread_mutex_lock($m); +} + +function spike_unlock(FFI\CData $m): int +{ + return libc()->pthread_mutex_unlock($m); +} + +/** + * Typed engine pointer at a raw address, through z-engine's FFI binding + * (needed for zval / zend_object / zend_array / zend_string views). + */ +function spike_engine_at(string $type, int $address): object +{ + return \ZEngine\Core::pointerAtAddress($type, $address); +} + +// --------------------------------------------------------------------------- +// process helpers +// --------------------------------------------------------------------------- + +/** @return array{0:resource,1:resource} */ +function spike_pipe(): array +{ + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); + if ($pair === false) { + throw new RuntimeException('stream_socket_pair failed'); + } + + return $pair; +} + +/** + * Forks $n children, runs $body($index) in each, exits the child with the returned code. + * + * @return list child pids + */ +function spike_fork(int $n, callable $body): array +{ + $pids = []; + for ($i = 0; $i < $n; $i++) { + $pid = pcntl_fork(); + if ($pid === -1) { + throw new RuntimeException('fork failed'); + } + if ($pid === 0) { + $code = 0; + try { + $code = (int) $body($i); + } catch (\Throwable $e) { + fwrite(STDERR, "child {$i} threw: " . get_class($e) . ': ' . $e->getMessage() . "\n"); + $code = 66; + } + // Hard exit: skip PHP shutdown so engine teardown never touches shared memory + spike_hard_exit($code); + } + $pids[] = $pid; + } + + return $pids; +} + +/** + * Leaves the child WITHOUT running PHP's shutdown sequence. + * + * Object/GC teardown in a forked child would walk (and free) memory that the parent and + * the siblings still own, so every spike child leaves through this door. + */ +function spike_hard_exit(int $code): never +{ + try { + libc()->_exit($code); + } catch (\Throwable) { + // fall through + } + exit($code); +} + +/** @return array pid => exit status description */ +function spike_wait(array $pids): array +{ + $result = []; + foreach ($pids as $pid) { + $status = 0; + pcntl_waitpid($pid, $status); + if (pcntl_wifexited($status)) { + $result[$pid] = ['exit' => pcntl_wexitstatus($status), 'signal' => null]; + } elseif (pcntl_wifsignaled($status)) { + $result[$pid] = ['exit' => null, 'signal' => pcntl_wtermsig($status)]; + } else { + $result[$pid] = ['exit' => null, 'signal' => null]; + } + } + + return $result; +} + +function spike_describe_wait(array $waits): string +{ + $parts = []; + foreach ($waits as $pid => $w) { + $parts[] = $w['signal'] !== null + ? sprintf('pid %d killed by signal %d (%s)', $pid, $w['signal'], spike_signame($w['signal'])) + : sprintf('pid %d exit %s', $pid, var_export($w['exit'], true)); + } + + return implode(', ', $parts); +} + +function spike_signame(int $sig): string +{ + $map = [4 => 'SIGILL', 6 => 'SIGABRT', 7 => 'SIGBUS', 8 => 'SIGFPE', 9 => 'SIGKILL', 11 => 'SIGSEGV']; + + return $map[$sig] ?? "sig{$sig}"; +} + +// --------------------------------------------------------------------------- +// reporting +// --------------------------------------------------------------------------- + +function spike_header(string $id, string $title): void +{ + printf("=== %s — %s ===\n", $id, $title); + printf("PHP %s (%s), ZTS=%s, pid=%d\n", PHP_VERSION, PHP_OS, ZEND_THREAD_SAFE ? 'yes' : 'no', getmypid()); + $err = spike_boot_zengine(); + printf("z-engine: %s\n\n", $err === null ? 'booted (' . spike_zengine_dir() . ')' : 'UNAVAILABLE — ' . $err); +} + +function spike_step(string $text): void +{ + printf("--- %s\n", $text); +} + +function spike_result(string $label, bool $ok, string $detail = ''): void +{ + printf("[%s] %s%s\n", $ok ? ' OK ' : 'FAIL', $label, $detail === '' ? '' : ' :: ' . $detail); +} + +function spike_note(string $text): void +{ + printf(" %s\n", $text); +} diff --git a/spikes/c1/out/S08_S15_mutex_and_bump-8.4.log b/spikes/c1/out/S08_S15_mutex_and_bump-8.4.log new file mode 100644 index 0000000..18f9fd0 --- /dev/null +++ b/spikes/c1/out/S08_S15_mutex_and_bump-8.4.log @@ -0,0 +1,26 @@ +=== S8/S15 — robust mutex recovery + bump-allocation race === +PHP 8.4.19 (Linux), ZTS=no, pid=28870 +z-engine: booted (/home/user/z-engine) + +--- S8a — child SIGKILLed while holding a ROBUST process-shared mutex + holder: pid 28871 killed by signal 9 (SIGKILL) + parent pthread_mutex_lock() returned 130 after 8.4 us (EOWNERDEAD == 130) +[ OK ] S8a lock on an orphaned robust mutex returns EOWNERDEAD (no deadlock) + consistent()=0 unlock()=0 then lock()=0 unlock()=0 +[ OK ] S8a pthread_mutex_consistent() restores the mutex +--- S8b — CONTROL: recover the EOWNERDEAD without calling consistent() + first lock() = 130, unlock without consistent(), next lock() = 131 (ENOTRECOVERABLE == 131) +[ OK ] S8b skipping consistent() poisons the mutex permanently :: the recovery handler is MANDATORY — a missed consistent() takes the whole arena down +--- S8c — CONTROL: a NON-robust pshared mutex whose owner dies + pthread_mutex_trylock() on the orphaned non-robust mutex = 16 (EBUSY == 16) +[ OK ] S8c a NON-robust pshared mutex is permanently stuck after an owner dies :: lock() here would block forever — PTHREAD_MUTEX_ROBUST is not optional for a multi-process arena + +--- S15a — 4 children, 25000 bump allocations each, UNDER the mutex + children: pid 28875 exit 0, pid 28876 exit 0, pid 28877 exit 0, pid 28879 exit 0 (0.19 s) + 100000 records, 11199712 bytes carved (bump 8388608 -> 19588320), 0 overlaps, 0 duplicate offsets, 0 corrupted blocks +[ OK ] S15a locked bump allocation: no overlaps, no duplicate offsets, no corruption +--- S15b — CONTROL: the identical run with NO mutex (up to 3 attempts; a race is probabilistic) + attempt 1 (0.05 s): 78088 records (expected 100000), 8671472 bytes carved, 2866 overlaps, 825 duplicate offsets, 4663 corrupted blocks +[ OK ] S15b unlocked bump allocation races (lost updates and overlapping blocks) :: the mutex in S15a is load-bearing, not decoration + +Done. diff --git a/spikes/c1/out/S08_S15_mutex_and_bump-8.5.log b/spikes/c1/out/S08_S15_mutex_and_bump-8.5.log new file mode 100644 index 0000000..7b967ec --- /dev/null +++ b/spikes/c1/out/S08_S15_mutex_and_bump-8.5.log @@ -0,0 +1,26 @@ +=== S8/S15 — robust mutex recovery + bump-allocation race === +PHP 8.5.9 (Linux), ZTS=no, pid=28939 +z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) + +--- S8a — child SIGKILLed while holding a ROBUST process-shared mutex + holder: pid 28940 killed by signal 9 (SIGKILL) + parent pthread_mutex_lock() returned 130 after 8.2 us (EOWNERDEAD == 130) +[ OK ] S8a lock on an orphaned robust mutex returns EOWNERDEAD (no deadlock) + consistent()=0 unlock()=0 then lock()=0 unlock()=0 +[ OK ] S8a pthread_mutex_consistent() restores the mutex +--- S8b — CONTROL: recover the EOWNERDEAD without calling consistent() + first lock() = 130, unlock without consistent(), next lock() = 131 (ENOTRECOVERABLE == 131) +[ OK ] S8b skipping consistent() poisons the mutex permanently :: the recovery handler is MANDATORY — a missed consistent() takes the whole arena down +--- S8c — CONTROL: a NON-robust pshared mutex whose owner dies + pthread_mutex_trylock() on the orphaned non-robust mutex = 16 (EBUSY == 16) +[ OK ] S8c a NON-robust pshared mutex is permanently stuck after an owner dies :: lock() here would block forever — PTHREAD_MUTEX_ROBUST is not optional for a multi-process arena + +--- S15a — 4 children, 25000 bump allocations each, UNDER the mutex + children: pid 28943 exit 0, pid 28944 exit 0, pid 28945 exit 0, pid 28946 exit 0 (0.21 s) + 100000 records, 11199712 bytes carved (bump 8388608 -> 19588320), 0 overlaps, 0 duplicate offsets, 0 corrupted blocks +[ OK ] S15a locked bump allocation: no overlaps, no duplicate offsets, no corruption +--- S15b — CONTROL: the identical run with NO mutex (up to 3 attempts; a race is probabilistic) + attempt 1 (0.09 s): 80243 records (expected 100000), 9428128 bytes carved, 2546 overlaps, 737 duplicate offsets, 5341 corrupted blocks +[ OK ] S15b unlocked bump allocation races (lost updates and overlapping blocks) :: the mutex in S15a is load-bearing, not decoration + +Done. diff --git a/spikes/c1/out/S12_cross_process_mutation-8.4.log b/spikes/c1/out/S12_cross_process_mutation-8.4.log new file mode 100644 index 0000000..b52e344 --- /dev/null +++ b/spikes/c1/out/S12_cross_process_mutation-8.4.log @@ -0,0 +1,35 @@ +=== S12 — cross-process mutation visibility === +PHP 8.4.19 (Linux), ZTS=no, pid=28820 +z-engine: booted (/home/user/z-engine) + +arena: 0x7fb8da600000 .. 0x7fb8daa00000 (4194304 bytes, MAP_SHARED|MAP_ANONYMOUS) + +--- A — 1000000 iterations, 1 writer child + 1 locked reader child + 1 UNLOCKED reader child +children: pid 28821 exit 0, pid 28822 exit 0, pid 28823 exit 0 (1.36 s, 737170 writes/s) +[ OK ] A1 locked reader: 1090181 reads, 0 inconsistent observations + locked reader last observed generation 1000000 of 1000000 (progress proves visibility) +[ OK ] A2 unlocked reader: 8615400 reads, 182045 value/mirror mismatches, 116039 value-vs-type mismatches :: EXPECTED: a 16-byte zval is NOT atomic; readers need the lock + +--- B1 — NEGATIVE CONTROL: persistent (malloc) clone + fork == copy-on-write + persistent clone at 0x55dad3612f70, object size 88 bytes (malloc/pemalloc heap) + child wrote counter=424242 (child read back 424242) +[ OK ] B1 parent still sees counter=100 :: CONFIRMED: malloc memory is COW across fork — mutations are NOT shared + +--- B2 — THE PREMISE: byte-copy the engine-formatted object into MAP_SHARED and re-anchor + arena object at 0x7fb8da601000, handle 37, spl_object_id=37, class=S12Holder +[ OK ] B2 pre-fork read-back through the arena instance + children: pid 28825 exit 0, pid 28826 exit 0, pid 28827 exit 0 (0.24 s) + LOCKED reader: 169107 reads, 0 inconsistent, highest counter observed 200000 of 200000, max value age 166.4 us + UNLOCKED reader: 2607122 reads, 89409 inconsistent (3.43%) +[ OK ] B2 cross-process visibility of engine property writes :: parent now reads counter=200000 ratio=50000.0 flag=false (written only by a child) +[ OK ] B2 unlocked multi-property reads are inconsistent :: EXPECTED: multi-slot updates are not atomic — a reader must hold the same lock +[ OK ] B2 parent observes the LAST child write :: expected 200000 +[ OK ] B2 arena object identity stable in parent + +--- C — child bump-allocates a NEW shared object POST-fork; the parent attaches it by address + child: pid 28828 exit 0; it published a 88-byte object at 0x7fb8da611000 (8 bytes over the pipe, no serialization) + parent attached it: class=S12Holder counter=31337 ratio=2.5 flag=true +[ OK ] C an object created by a child post-fork is readable by the parent + the child had already exited: only the arena bytes survive, and that is enough + +Done. diff --git a/spikes/c1/out/S12_cross_process_mutation-8.5.log b/spikes/c1/out/S12_cross_process_mutation-8.5.log new file mode 100644 index 0000000..5278fbf --- /dev/null +++ b/spikes/c1/out/S12_cross_process_mutation-8.5.log @@ -0,0 +1,35 @@ +=== S12 — cross-process mutation visibility === +PHP 8.5.9 (Linux), ZTS=no, pid=28889 +z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) + +arena: 0x7f2c2b400000 .. 0x7f2c2b800000 (4194304 bytes, MAP_SHARED|MAP_ANONYMOUS) + +--- A — 1000000 iterations, 1 writer child + 1 locked reader child + 1 UNLOCKED reader child +children: pid 28890 exit 0, pid 28891 exit 0, pid 28892 exit 0 (1.25 s, 802988 writes/s) +[ OK ] A1 locked reader: 977876 reads, 0 inconsistent observations + locked reader last observed generation 1000000 of 1000000 (progress proves visibility) +[ OK ] A2 unlocked reader: 7466423 reads, 289045 value/mirror mismatches, 139907 value-vs-type mismatches :: EXPECTED: a 16-byte zval is NOT atomic; readers need the lock + +--- B1 — NEGATIVE CONTROL: persistent (malloc) clone + fork == copy-on-write + persistent clone at 0x562658b23680, object size 88 bytes (malloc/pemalloc heap) + child wrote counter=424242 (child read back 424242) +[ OK ] B1 parent still sees counter=100 :: CONFIRMED: malloc memory is COW across fork — mutations are NOT shared + +--- B2 — THE PREMISE: byte-copy the engine-formatted object into MAP_SHARED and re-anchor + arena object at 0x7f2c2b401000, handle 36, spl_object_id=36, class=S12Holder +[ OK ] B2 pre-fork read-back through the arena instance + children: pid 28894 exit 0, pid 28895 exit 0, pid 28896 exit 0 (0.23 s) + LOCKED reader: 178116 reads, 0 inconsistent, highest counter observed 200000 of 200000, max value age 106.9 us + UNLOCKED reader: 3064947 reads, 82000 inconsistent (2.68%) +[ OK ] B2 cross-process visibility of engine property writes :: parent now reads counter=200000 ratio=50000.0 flag=false (written only by a child) +[ OK ] B2 unlocked multi-property reads are inconsistent :: EXPECTED: multi-slot updates are not atomic — a reader must hold the same lock +[ OK ] B2 parent observes the LAST child write :: expected 200000 +[ OK ] B2 arena object identity stable in parent + +--- C — child bump-allocates a NEW shared object POST-fork; the parent attaches it by address + child: pid 28897 exit 0; it published a 88-byte object at 0x7f2c2b411000 (8 bytes over the pipe, no serialization) + parent attached it: class=S12Holder counter=31337 ratio=2.5 flag=true +[ OK ] C an object created by a child post-fork is readable by the parent + the child had already exited: only the arena bytes survive, and that is enough + +Done. diff --git a/spikes/c1/out/S13_shared_ardata-8.4.log b/spikes/c1/out/S13_shared_ardata-8.4.log new file mode 100644 index 0000000..9f265fa --- /dev/null +++ b/spikes/c1/out/S13_shared_ardata-8.4.log @@ -0,0 +1,39 @@ +=== S13 — pre-sized arData in shared memory === +PHP 8.4.19 (Linux), ZTS=no, pid=28832 +z-engine: booted (/home/user/z-engine) + +arena 0x7f497cc00000, sizeof(zend_array)=56, sizeof(Bucket)=32, sizeof(zval)=16 + +--- A — build a 64-entry persistent table and relocate it into the arena + source table: flags=0x10 packed=false nTableSize=64 nNumUsed=64 nNumOfElements=64 + nTableMask=-128 HT_HASH_SIZE=512 HT_DATA_SIZE=2048 one block of 2560 bytes at 0x56171ebfbeb0 + arena table: struct at 0x7f497cc00400, block at 0x7f497cc01000, arData at 0x7f497cc01200 (inside arena: true) + bucket KEYS still point at malloc-interned strings (COW-shared, fine across fork; see S16) +--- B — materialize a PHP array zval pointing at the arena table + zval type_info = 0x7 (GC_IMMUTABLE => non-refcounted IS_ARRAY = 0x7) +[ OK ] B count() :: got 64 +[ OK ] B lookup k7 :: 70 +[ OK ] B array_sum over foreach :: sum=20160 +[ OK ] B z-engine HashTable view count :: got 64 +--- C/D — 1 mutator child + 2 reader children, in-place scalar bucket overwrite under mutex + bucket[7].val zval at 0x7f497cc012e0 + children: pid 28833 exit 0, pid 28834 exit 0, pid 28835 exit 0 (0.18 s) + value reader: 157828 locked reads, 0 anomalies, highest 200000 of 200000 + structural reader: 78054 full foreach+count walks, 0 anomalies +[ OK ] C concurrent foreach/count over a shared-memory table +[ OK ] D in-place scalar bucket overwrite is visible cross-process +[ OK ] D parent observes the last child write :: parent reads k7=200000 (expected 200000) + +--- E — THE TRAP: what happens when the table has to grow + arena2 0x7f497d4c3000 .. 0x7f497d5c3000; small table nTableSize=8 nNumUsed=6 arData=0x7f497d4c4040 (in arena: true) +free(): invalid pointer + growth child: pid 28836 killed by signal 6 (SIGABRT) + the child never reported the move itself: it aborted inside the resize (see the signal above) + parent now reads ht->arData = 0x56171ebfb310 (inside arena2: false) +[ OK ] E growth is DETECTABLE (arData pointer changes in the shared struct) :: the shared struct was rewritten by the child +[ OK ] E grown arData points OUTSIDE the shared arena :: the parent would now dereference the dead child's private heap: DANGLING + post-growth foreach child: pid 28837 exit 0 + it walked 8 entries summing to 280; the shared struct claims nNumOfElements=8 nTableSize=16 +[ OK ] E a sibling reading the grown table gets SILENT garbage, not a crash :: walked 8 of the 8 elements the struct advertises — no fault, no signal, just wrong data + +Done. diff --git a/spikes/c1/out/S13_shared_ardata-8.5.log b/spikes/c1/out/S13_shared_ardata-8.5.log new file mode 100644 index 0000000..1643a4f --- /dev/null +++ b/spikes/c1/out/S13_shared_ardata-8.5.log @@ -0,0 +1,39 @@ +=== S13 — pre-sized arData in shared memory === +PHP 8.5.9 (Linux), ZTS=no, pid=28901 +z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) + +arena 0x7f548f200000, sizeof(zend_array)=56, sizeof(Bucket)=32, sizeof(zval)=16 + +--- A — build a 64-entry persistent table and relocate it into the arena + source table: flags=0x10 packed=false nTableSize=64 nNumUsed=64 nNumOfElements=64 + nTableMask=-128 HT_HASH_SIZE=512 HT_DATA_SIZE=2048 one block of 2560 bytes at 0x556660e16430 + arena table: struct at 0x7f548f200400, block at 0x7f548f201000, arData at 0x7f548f201200 (inside arena: true) + bucket KEYS still point at malloc-interned strings (COW-shared, fine across fork; see S16) +--- B — materialize a PHP array zval pointing at the arena table + zval type_info = 0x7 (GC_IMMUTABLE => non-refcounted IS_ARRAY = 0x7) +[ OK ] B count() :: got 64 +[ OK ] B lookup k7 :: 70 +[ OK ] B array_sum over foreach :: sum=20160 +[ OK ] B z-engine HashTable view count :: got 64 +--- C/D — 1 mutator child + 2 reader children, in-place scalar bucket overwrite under mutex + bucket[7].val zval at 0x7f548f2012e0 + children: pid 28902 exit 0, pid 28903 exit 0, pid 28904 exit 0 (0.19 s) + value reader: 183516 locked reads, 0 anomalies, highest 200000 of 200000 + structural reader: 112392 full foreach+count walks, 0 anomalies +[ OK ] C concurrent foreach/count over a shared-memory table +[ OK ] D in-place scalar bucket overwrite is visible cross-process +[ OK ] D parent observes the last child write :: parent reads k7=200000 (expected 200000) + +--- E — THE TRAP: what happens when the table has to grow + arena2 0x7f548f100000 .. 0x7f548f200000; small table nTableSize=8 nNumUsed=6 arData=0x7f548f101040 (in arena: true) +free(): invalid pointer + growth child: pid 28905 killed by signal 6 (SIGABRT) + the child never reported the move itself: it aborted inside the resize (see the signal above) + parent now reads ht->arData = 0x556660dbc410 (inside arena2: false) +[ OK ] E growth is DETECTABLE (arData pointer changes in the shared struct) :: the shared struct was rewritten by the child +[ OK ] E grown arData points OUTSIDE the shared arena :: the parent would now dereference the dead child's private heap: DANGLING + post-growth foreach child: pid 28906 exit 0 + it walked 8 entries summing to 280; the shared struct claims nNumOfElements=8 nTableSize=16 +[ OK ] E a sibling reading the grown table gets SILENT garbage, not a crash :: walked 8 of the 8 elements the struct advertises — no fault, no signal, just wrong data + +Done. diff --git a/spikes/c1/out/S14_attach_side_effects-8.4.log b/spikes/c1/out/S14_attach_side_effects-8.4.log new file mode 100644 index 0000000..7c0fe6e --- /dev/null +++ b/spikes/c1/out/S14_attach_side_effects-8.4.log @@ -0,0 +1,43 @@ +=== S14 — per-process side effects of attach === +PHP 8.4.19 (Linux), ZTS=no, pid=28841 +z-engine: booted (/home/user/z-engine) + +shared zend_object at 0x7fc272eed000, 72 bytes; handle field currently 25, properties=0x0 + +--- A — parent attaches, then two children attach the same object simultaneously + parent put() -> handle 36, obj->handle=36, spl_object_id=36 + children: pid 28842 exit 0, pid 28843 exit 0 + child 0: put() returned handle 30, spl_object_id right after = 30; 20 ms later obj->handle=30 and spl_object_id=30 + child 1: put() returned handle 30, spl_object_id right after = 30; 20 ms later obj->handle=30 and spl_object_id=30 + parent afterwards: obj->handle=30, spl_object_id($parentInstance)=30 (parent's real slot is 36) +[ OK ] A obj->handle is a SHARED field every attaching process overwrites :: parent attached at slot 36, shared field now says 30 +[ OK ] A spl_object_id() in the parent is now WRONG :: spl_object_id() reads obj->handle directly — it returns a foreign process's slot number + parent's object store slot 30 currently holds: a DIFFERENT live object (FFI\CData) + recycle()/detach() at request end would therefore return a FOREIGN slot to the free list + +--- B — obj->properties: the lazy request-heap pointer written into a shared struct + before: obj->properties = 0x0 + trigger child: pid 28844 exit 0 + inside child A: properties 0x0 -> get_object_vars(2 vars) -> 0x7fc276d17968 -> var_dump -> 0x7fc276d17968 -> json_encode(22 bytes) -> 0x7fc276d17968 -> (array) cast(2) -> 0x7fc276d17968 + PARENT now reads obj->properties = 0x7fc276d17968 (child A is gone; that is child A's private heap) +[ OK ] B a read-only-looking call writes a request-heap pointer into the SHARED struct :: CONFIRMED: obj->properties is non-NULL in the shared struct after a child called get_object_vars()/var_dump() +--- B2 — sibling child B follows the inherited obj->properties pointer + sibling child: pid 28845 exit 0 + progress markers: reached=1, get_object_vars returned 1 vars (done=1), var_dump produced 50 bytes (done=1) +[ OK ] B2 sibling outcome :: survived but read 1 "properties" out of a heap block it never wrote — silent garbage +--- B3 — what a process that did NOT inherit the writer's heap sees + obj->properties forced to 0x7fc276eec000 (unmapped in every process) + child: pid 28846 killed by signal 11 (SIGSEGV) (reached=1, returned 0 vars) +[ OK ] B3 dereferencing a foreign obj->properties kills the process :: SIGNAL 11 (SIGSEGV) — hard crash + the same field is also written by: property_exists on dynamic props, iteration over the object, + serialize(), debug_zval_dump(), Reflection*::getProperties() and every (array)/json path. + +--- C — obj->ce and obj->handlers: which of them is really fork-stable? + parent: std_object_handlers=0x55780f8ae920, S14Holder ce=0x7fc276c04018 + child 0: handlers=0x55780f8ae920 S14Holder ce=0x7fc276c04018 post-fork S14LateClass ce=0x7fc272a587d0 + child 1: handlers=0x55780f8ae920 S14Holder ce=0x7fc276c04018 post-fork S14LateClass ce=0x7fc272a51d90 +[ OK ] C std_object_handlers is address-identical in every forked process :: safe to keep INSIDE the shared struct +[ OK ] C a PRE-fork class entry is address-identical too :: obj->ce happens to agree — but only because the class was loaded before the fork +[ OK ] C a POST-fork class entry differs per process :: 0x7fc272a587d0 vs 0x7fc272a51d90 — obj->ce cannot be a shared field once classes are autoloaded lazily + +Done. diff --git a/spikes/c1/out/S14_attach_side_effects-8.5.log b/spikes/c1/out/S14_attach_side_effects-8.5.log new file mode 100644 index 0000000..77cc811 --- /dev/null +++ b/spikes/c1/out/S14_attach_side_effects-8.5.log @@ -0,0 +1,43 @@ +=== S14 — per-process side effects of attach === +PHP 8.5.9 (Linux), ZTS=no, pid=28910 +z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) + +shared zend_object at 0x7f58a742a000, 72 bytes; handle field currently 25, properties=0x0 + +--- A — parent attaches, then two children attach the same object simultaneously + parent put() -> handle 35, obj->handle=35, spl_object_id=35 + children: pid 28911 exit 0, pid 28912 exit 0 + child 0: put() returned handle 36, spl_object_id right after = 36; 20 ms later obj->handle=36 and spl_object_id=36 + child 1: put() returned handle 36, spl_object_id right after = 36; 20 ms later obj->handle=36 and spl_object_id=36 + parent afterwards: obj->handle=36, spl_object_id($parentInstance)=36 (parent's real slot is 35) +[ OK ] A obj->handle is a SHARED field every attaching process overwrites :: parent attached at slot 35, shared field now says 36 +[ OK ] A spl_object_id() in the parent is now WRONG :: spl_object_id() reads obj->handle directly — it returns a foreign process's slot number + parent's object store slot 36 currently holds: a DIFFERENT live object (FFI\CData) + recycle()/detach() at request end would therefore return a FOREIGN slot to the free list + +--- B — obj->properties: the lazy request-heap pointer written into a shared struct + before: obj->properties = 0x0 + trigger child: pid 28913 exit 0 + inside child A: properties 0x0 -> get_object_vars(2 vars) -> 0x7f58a92ec818 -> var_dump -> 0x7f58a92ec818 -> json_encode(22 bytes) -> 0x7f58a92ec818 -> (array) cast(2) -> 0x7f58a92ec818 + PARENT now reads obj->properties = 0x7f58a92ec818 (child A is gone; that is child A's private heap) +[ OK ] B a read-only-looking call writes a request-heap pointer into the SHARED struct :: CONFIRMED: obj->properties is non-NULL in the shared struct after a child called get_object_vars()/var_dump() +--- B2 — sibling child B follows the inherited obj->properties pointer + sibling child: pid 28914 exit 0 + progress markers: reached=1, get_object_vars returned 1 vars (done=1), var_dump produced 50 bytes (done=1) +[ OK ] B2 sibling outcome :: survived but read 1 "properties" out of a heap block it never wrote — silent garbage +--- B3 — what a process that did NOT inherit the writer's heap sees + obj->properties forced to 0x7f58ab429000 (unmapped in every process) + child: pid 28915 killed by signal 11 (SIGSEGV) (reached=1, returned 0 vars) +[ OK ] B3 dereferencing a foreign obj->properties kills the process :: SIGNAL 11 (SIGSEGV) — hard crash + the same field is also written by: property_exists on dynamic props, iteration over the object, + serialize(), debug_zval_dump(), Reflection*::getProperties() and every (array)/json path. + +--- C — obj->ce and obj->handlers: which of them is really fork-stable? + parent: std_object_handlers=0x55ba43af0760, S14Holder ce=0x55ba378d8760 + child 0: handlers=0x55ba43af0760 S14Holder ce=0x55ba378d8760 post-fork S14LateClass ce=0x7f58a7235d58 + child 1: handlers=0x55ba43af0760 S14Holder ce=0x55ba378d8760 post-fork S14LateClass ce=0x7f58a722f318 +[ OK ] C std_object_handlers is address-identical in every forked process :: safe to keep INSIDE the shared struct +[ OK ] C a PRE-fork class entry is address-identical too :: obj->ce happens to agree — but only because the class was loaded before the fork +[ OK ] C a POST-fork class entry differs per process :: 0x7f58a7235d58 vs 0x7f58a722f318 — obj->ce cannot be a shared field once classes are autoloaded lazily + +Done. diff --git a/spikes/c1/out/S16_string_swap-8.4.log b/spikes/c1/out/S16_string_swap-8.4.log new file mode 100644 index 0000000..11c3dd3 --- /dev/null +++ b/spikes/c1/out/S16_string_swap-8.4.log @@ -0,0 +1,26 @@ +=== S16 — string swap visibility === +PHP 8.4.19 (Linux), ZTS=no, pid=28852 +z-engine: booted (/home/user/z-engine) + +--- A — arena-intern two zend_strings + A at 0x7faa30e00400 (48 bytes) len=23 interned=true value=alpha-alpha-alpha-alpha + B at 0x7faa30e00600 (60 bytes) len=35 interned=true value=BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO +[ OK ] A both strings readable from the arena + $name slot zval at 0x7faa30e01028 (value word 8-byte aligned: true) +[ OK ] A2 property reads through the arena string :: 'alpha-alpha-alpha-alpha' + +--- B/C — 300000 pointer swaps by child 0; child 1 reads LOCKED, child 2 reads UNLOCKED + children: pid 28853 exit 0, pid 28854 exit 0, pid 28855 exit 0 (0.32 s) + LOCKED reader: 267587 reads (A=135833 B=131754), 0 torn, max staleness 205.7 us + UNLOCKED reader: 2022502 reads, 0 torn pointers, 0 unexpected string values, max staleness 207.8 us +[ OK ] B locked pointer swap: never torn, both values observed +[ OK ] C UNLOCKED aligned 8-byte pointer swap: never torn either :: aligned 8-byte loads/stores are atomic on x86-64 — the lock buys ORDERING between slots, not per-pointer atomicity + parent reads $shared->name = 'alpha-alpha-alpha-alpha' + +--- D — control: the same 8-byte swap on a slot straddling a 4 KiB page boundary + slot at 0x7faa30e02ffc: offset % 4096 = 4092, offset % 64 = 60 — the 8 bytes span two pages + children: pid 28856 exit 0, pid 28857 exit 0 + misaligned unlocked reader: 1811937 reads, 0 TORN values +[ OK ] D misaligned (page-straddling) unlocked reads :: no tearing observed on this CPU, but the ISA gives no guarantee for a misaligned access — keep the alignment invariant + +Done. diff --git a/spikes/c1/out/S16_string_swap-8.5.log b/spikes/c1/out/S16_string_swap-8.5.log new file mode 100644 index 0000000..d3ab045 --- /dev/null +++ b/spikes/c1/out/S16_string_swap-8.5.log @@ -0,0 +1,26 @@ +=== S16 — string swap visibility === +PHP 8.5.9 (Linux), ZTS=no, pid=28921 +z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) + +--- A — arena-intern two zend_strings + A at 0x7feba1400400 (48 bytes) len=23 interned=true value=alpha-alpha-alpha-alpha + B at 0x7feba1400600 (60 bytes) len=35 interned=true value=BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO +[ OK ] A both strings readable from the arena + $name slot zval at 0x7feba1401028 (value word 8-byte aligned: true) +[ OK ] A2 property reads through the arena string :: 'alpha-alpha-alpha-alpha' + +--- B/C — 300000 pointer swaps by child 0; child 1 reads LOCKED, child 2 reads UNLOCKED + children: pid 28922 exit 0, pid 28923 exit 0, pid 28924 exit 0 (0.32 s) + LOCKED reader: 251818 reads (A=126441 B=125377), 0 torn, max staleness 456.2 us + UNLOCKED reader: 2616769 reads, 0 torn pointers, 0 unexpected string values, max staleness 458.3 us +[ OK ] B locked pointer swap: never torn, both values observed +[ OK ] C UNLOCKED aligned 8-byte pointer swap: never torn either :: aligned 8-byte loads/stores are atomic on x86-64 — the lock buys ORDERING between slots, not per-pointer atomicity + parent reads $shared->name = 'alpha-alpha-alpha-alpha' + +--- D — control: the same 8-byte swap on a slot straddling a 4 KiB page boundary + slot at 0x7feba1402ffc: offset % 4096 = 4092, offset % 64 = 60 — the 8 bytes span two pages + children: pid 28925 exit 0, pid 28926 exit 0 + misaligned unlocked reader: 2619302 reads, 0 TORN values +[ OK ] D misaligned (page-straddling) unlocked reads :: no tearing observed on this CPU, but the ISA gives no guarantee for a misaligned access — keep the alignment invariant + +Done. diff --git a/spikes/c1/out/S17_closures_across_fork-8.4.log b/spikes/c1/out/S17_closures_across_fork-8.4.log new file mode 100644 index 0000000..65dff67 --- /dev/null +++ b/spikes/c1/out/S17_closures_across_fork-8.4.log @@ -0,0 +1,54 @@ +=== S17 — closures across fork === +PHP 8.4.19 (Linux), ZTS=no, pid=28861 +z-engine: booted (/home/user/z-engine) + +--- (a) closures compiled PRE-fork, invoked concurrently by two children + static closure: zend_closure at 0x7fe69e729280, handle 24, fn_flags=0x82402110 (HEAP_RT_CACHE=false), op_array.opcodes=0x7fe6a266b500 + use closure: zend_closure at 0x7fe69e729400, handle 23, fn_flags=0x82402100 (HEAP_RT_CACHE=false), op_array.opcodes=0x7fe6a266b780 + bound closure: zend_closure at 0x7fe69e729580, handle 26, fn_flags=0x82422101 (HEAP_RT_CACHE=false), op_array.opcodes=0x7fe6a26a5200 + children: pid 28862 exit 0, pid 28863 exit 0 (0.02 s, 100000 invocations each of 3 closures) + child 0: 0 wrong results, checksum 55100050000, closure spl_object_id 24 + child 1: 0 wrong results, checksum 55100050000, closure spl_object_id 24 +[ OK ] (a) pre-fork closures invoke correctly and identically in both children :: op_array, literals and the captured statics are all COW-shared read-only data + run_time_cache is per-closure heap memory (ZEND_ACC_HEAP_RT_CACHE): each child COW-copies its own + +--- (b) closure created POST-fork in child A, its address handed to child B over a pipe + child A built the closure at 0x7fe69e729700; in child A it returns 987654322 for input 1 + child B: pid 28865 killed by signal 11 (SIGSEGV) + markers: reached=1 materialized=1 is_object=1 is_Closure=1 invoked=1 survived=0 result=0 +[ OK ] (b) invoking a sibling-built closure by address is UNSAFE :: child B died with signal 11 (SIGSEGV) + every post-fork allocation lands on a private COW page; addresses are only meaningful + inside the process that allocated them. Closures therefore cannot be shared by address. + +--- (c) every pointer a zend_closure carries (feasibility of arena-cloning) + field address notes + zend_closure (whole struct) 0x7fe69e729700 + std.ce (Closure class entry) 0x563745285380 + std.handlers 0x56370db2ab80 + func.op_array.function_name 0x7fe6a265c960 + func.op_array.scope 0x0 (null) + func.op_array.arg_info 0x7fe6a2670960 + func.op_array.attributes 0x0 (null) + func.op_array.run_time_cache__ptr 0x7fe69e7ceec8 + func.op_array.opcodes 0x7fe6a2671780 + func.op_array.static_variables 0x7fe69e7e6a10 + func.op_array.static_variables_ptr__ptr 0x7fe69e7e6a10 + func.op_array.vars 0x7fe6a265d0a8 + func.op_array.refcount 0x7fe6a265e180 + func.op_array.literals 0x7fe6a26718c0 + func.op_array.filename 0x7fe6a265c140 + func.op_array.dynamic_func_defs 0x0 (null) + func.op_array.live_range 0x7fe6a265e190 + func.op_array.try_catch_array 0x0 (null) + this_ptr.value 0x0 (null) + called_scope 0x0 (null) + counts: num_args=1 last_var=3 T=3 last(opcodes)=10 last_literal=1 cache_size=0 num_dynamic_func_defs=0 + byte cost of a deep clone: opcodes 10*32=320, literals 1*16=16, vars 3*8=24, arg_info 1*32=32 +[ OK ] (c) pointer inventory taken :: 14 of 20 zend_closure/op_array pointer fields are non-NULL for a trivial closure + child sees the SAME closure at 0x7fe69e729700 (opcodes 0x7fe6a2671780, literals 0x7fe6a26718c0, static_variables 0x7fe69e7e6a10, handle 30) +[ OK ] (c) a pre-fork closure keeps identical addresses in the child + run_time_cache__ptr and static_variables_ptr__ptr point into the REQUEST arena, not into + the compiled op_array: an arena-resident closure would share those per-request slots + between processes. Any closure design must re-mint them per process. + +Done. diff --git a/spikes/c1/out/S17_closures_across_fork-8.5.log b/spikes/c1/out/S17_closures_across_fork-8.5.log new file mode 100644 index 0000000..abd070f --- /dev/null +++ b/spikes/c1/out/S17_closures_across_fork-8.5.log @@ -0,0 +1,60 @@ +=== S17 — closures across fork === +PHP 8.5.9 (Linux), ZTS=no, pid=28930 +z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) + +--- (a) closures compiled PRE-fork, invoked concurrently by two children + static closure: zend_closure at 0x7f2fe7e79c00, handle 24, fn_flags=0x82402110 (HEAP_RT_CACHE=false), op_array.opcodes=0x559cd24e0c70 + use closure: zend_closure at 0x7f2fe5e23900, handle 23, fn_flags=0x82402100 (HEAP_RT_CACHE=false), op_array.opcodes=0x559cd24e0f10 + bound closure: zend_closure at 0x7f2fe5e23780, handle 26, fn_flags=0x86422101 (HEAP_RT_CACHE=true), op_array.opcodes=0x559cd24d8cc8 + children: pid 28931 exit 0, pid 28932 exit 0 (0.02 s, 100000 invocations each of 3 closures) + child 0: 0 wrong results, checksum 55100050000, closure spl_object_id 24 + child 1: 0 wrong results, checksum 55100050000, closure spl_object_id 24 +[ OK ] (a) pre-fork closures invoke correctly and identically in both children :: op_array, literals and the captured statics are all COW-shared read-only data + run_time_cache is per-closure heap memory (ZEND_ACC_HEAP_RT_CACHE): each child COW-copies its own + +--- (b) closure created POST-fork in child A, its address handed to child B over a pipe + child A built the closure at 0x7f2fe7e79780; in child A it returns 987654322 for input 1 +PHP Fatal error: Uncaught TypeError: spl_object_id(): Argument #1 ($object) must be of type object, null given in /tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php:113 +Stack trace: +#0 /tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php(113): spl_object_id() +#1 /tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php(177): {closure:/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php:99}() +#2 {main} + thrown in /tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php on line 113 + child B: pid 28934 exit 255 + markers: reached=1 materialized=1 is_object=1 is_Closure=1 invoked=1 survived=0 result=0 +[ OK ] (b) invoking a sibling-built closure by address is UNSAFE :: child B invoked it and got 0 instead of 987654322 + every post-fork allocation lands on a private COW page; addresses are only meaningful + inside the process that allocated them. Closures therefore cannot be shared by address. + +--- (c) every pointer a zend_closure carries (feasibility of arena-cloning) + field address notes + zend_closure (whole struct) 0x7f2fe7e79780 + std.ce (Closure class entry) 0x559d05e38b20 + std.handlers 0x559cde7cf520 + func.op_array.function_name 0x559cd1e67b70 + func.op_array.scope 0x0 (null) + func.op_array.arg_info 0x559cd24e2010 + func.op_array.attributes 0x0 (null) + func.op_array.run_time_cache__ptr 0x7f2fe5e2ca98 + func.op_array.opcodes 0x559cd24e1ef0 + func.op_array.static_variables 0x559cd24e1eb8 + func.op_array.static_variables_ptr__ptr 0x7f2fe7f57e00 + func.op_array.vars 0x559cd24e2040 + func.op_array.refcount 0x0 (null) + func.op_array.literals 0x0 (null) + func.op_array.filename 0x559cd24d8b08 + func.op_array.dynamic_func_defs 0x0 (null) + func.op_array.live_range 0x559cd24e2030 + func.op_array.try_catch_array 0x0 (null) + this_ptr.value 0x0 (null) + called_scope 0x0 (null) + counts: num_args=1 last_var=3 T=2 last(opcodes)=8 last_literal=0 cache_size=8 num_dynamic_func_defs=0 + byte cost of a deep clone: opcodes 8*32=256, literals 0*16=0, vars 3*8=24, arg_info 1*32=32 +[ OK ] (c) pointer inventory taken :: 12 of 20 zend_closure/op_array pointer fields are non-NULL for a trivial closure + child sees the SAME closure at 0x7f2fe7e79780 (opcodes 0x559cd24e1ef0, literals 0x0, static_variables 0x559cd24e1eb8, handle 27) +[ OK ] (c) a pre-fork closure keeps identical addresses in the child + run_time_cache__ptr and static_variables_ptr__ptr point into the REQUEST arena, not into + the compiled op_array: an arena-resident closure would share those per-request slots + between processes. Any closure design must re-mint them per process. + +Done. diff --git a/spikes/c1/run-all.sh b/spikes/c1/run-all.sh new file mode 100755 index 0000000..f241b69 --- /dev/null +++ b/spikes/c1/run-all.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Runs every spike on every supported PHP minor and stores the logs under out/. +# +# ./run-all.sh # php8.4 and php8.5 +# ./run-all.sh php8.4 # one binary +set -u + +cd "$(dirname "$0")" || exit 1 +mkdir -p out + +BINS=("$@") +if [ ${#BINS[@]} -eq 0 ]; then + BINS=(php8.4 php8.5) +fi + +SPIKES=( + S12_cross_process_mutation.php + S13_shared_ardata.php + S14_attach_side_effects.php + S16_string_swap.php + S17_closures_across_fork.php + S08_S15_mutex_and_bump.php +) + +for bin in "${BINS[@]}"; do + ver=$("$bin" -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;') + for spike in "${SPIKES[@]}"; do + log="out/${spike%.php}-${ver}.log" + printf '=== %s on %s -> %s\n' "$spike" "$bin" "$log" + timeout 900 "$bin" -d ffi.enable=1 -d opcache.jit=off "$spike" > "$log" 2>&1 + printf ' exit %d, %d OK / %d FAIL\n' "$?" \ + "$(grep -c '^\[ OK \]' "$log")" "$(grep -c '^\[FAIL\]' "$log")" + done +done diff --git a/spikes/c1/verdicts.md b/spikes/c1/verdicts.md new file mode 100644 index 0000000..4de3b0d --- /dev/null +++ b/spikes/c1/verdicts.md @@ -0,0 +1,459 @@ +# Spike verdicts — zero-serialization shared-object arena + +Agent **C1** (spike/validation). EPIC: [php-shared-data-extension#15](https://github.com/lisachenko/php-shared-data-extension/issues/15). + +Everything here was run **on both supported minors** and every check is green on both: + +| | PHP 8.4.19 (NTS) | PHP 8.5.9 (NTS) | +|---|---|---| +| S12 cross-process mutation | 9 OK / 0 FAIL | 9 OK / 0 FAIL | +| S13 pre-sized arData | 10 / 0 | 10 / 0 | +| S14 attach side effects | 8 / 0 | 8 / 0 | +| S16 string swap | 5 / 0 | 5 / 0 | +| S17 closures across fork | 4 / 0 | 4 / 0 | +| S8/S15 mutex + bump | 6 / 0 | 6 / 0 | + +**Headline: the premise holds.** An engine-formatted `zend_object` placed in +`MAP_SHARED|MAP_ANONYMOUS` memory can be attached as an ordinary PHP instance in several +forked processes at once, and a plain `$obj->prop = ...` in one process is immediately +visible to the others. Everything below is about the sharp edges around that fact. + +--- + +## How to reproduce + +``` +spikes/ + lib/bootstrap.php harness: PSR-4 autoload for ZEngine\ + Lisachenko\SharedData\, + libc FFI binding (mmap / robust pshared mutexes), fork helpers + S12_cross_process_mutation.php + S13_shared_ardata.php + S14_attach_side_effects.php + S16_string_swap.php + S17_closures_across_fork.php + S08_S15_mutex_and_bump.php + run-all.sh runs everything on php8.4 + php8.5, logs into out/ + out/*.log captured evidence for the numbers quoted below + zengine-85/ shallow clone of z-engine `master` (the 8.5.x-dev line) +``` + +```bash +./run-all.sh # both minors +php8.4 -d ffi.enable=1 -d opcache.jit=off S12_cross_process_mutation.php +``` + +**Environment note the implementing agents need:** the checkout at `/home/user/z-engine` is +the **8.4 branch only** (`SUPPORTED_PHP_VERSION_ID = [80400, 80500)`, `include/8.4` only), so +running any z-engine-backed code under php8.5 against it aborts in `Core::init()`. The spikes +resolve the 8.5 line from a scratch clone of z-engine `master` (`spikes/zengine-85`). This is +a sandbox artifact, not a design finding — but any CI leg that exercises E1–E5 on 8.5 needs +Composer to actually resolve `8.5.x-dev`, and a local path repo pointing at the 8.4 checkout +will silently skip instead of failing. + +Structural fact worth recording: `zval` (16), `Bucket` (32), `zend_array` (56), +`zend_object` (56 + 16·(n-1)) and the whole `zend_op_array` field order are **byte-identical +between 8.4 and 8.5** on linux-x64-nts (`diff` of the two generated `engine.h` core sections +is empty). Nothing in the arena layout has to be versioned per minor beyond what z-engine +already versions. + +--- + +## S12 — cross-process mutation visibility · **GREEN** + +### Evidence + +**A. Hand-built zval slot, 10⁶ locked writes, 1 locked reader + 1 unlocked reader** + +``` +A1 locked reader: 1090181 reads, 0 inconsistent observations + locked reader last observed generation 1000000 of 1000000 +A2 unlocked reader: 8615400 reads, 182045 value/mirror mismatches, + 116039 value-vs-type mismatches +``` +(8.5: 977876 / 0, and 289045 + 139907 mismatches.) + +**B1. The motivating negative result — today's malloc path** + +``` +persistent clone at 0x55b1314e5e20, object size 88 bytes (malloc/pemalloc heap) +child wrote counter=424242 (child read back 424242) +[ OK ] B1 parent still sees counter=100 + CONFIRMED: malloc memory is COW across fork — mutations are NOT shared +``` + +**B2. The same object memcpy'd into the arena and re-anchored** + +``` +arena object at 0x7f5ede601000, handle 36, spl_object_id=36, class=S12Holder +LOCKED reader: 169107 reads, 0 inconsistent, highest counter observed 200000 of 200000, + max value age 166.4 us +UNLOCKED reader: 2607122 reads, 89409 inconsistent (3.43%) +parent now reads counter=200000 ratio=50000.0 flag=false (written only by a child) +``` + +**C. Reverse direction (E1 acceptance #2).** A child bump-allocated a *brand new* +`S12Holder` into the arena post-fork, applied the `persistentClone` GC surgery to the arena +block, and sent 8 bytes down a pipe. The parent attached it after the child had exited and +read `counter=31337 ratio=2.5 flag=true`. + +### Consequences + +- **E1 (#16):** the arena + bump-allocate + publish-address-over-a-pipe path works end to + end, in both directions, on both minors. `PersistentObjectFactory::persistentClone()`'s GC + surgery (`PIN_BASELINE`, `GC_OBJECT|GC_NOT_COLLECTABLE|GC_PERSISTENT`, + `IS_OBJ_DESTRUCTOR_CALLED|IS_OBJ_FREE_CALLED`, `handlers = std_object_handlers`, + `properties = NULL`) is exactly right for an arena block too — the only thing the Z1 + allocator seam has to change is *where the bytes come from*. +- **E2 (#17):** scalar in-place property writes are visible cross-process **immediately** — + no flush, no barrier, no re-attach. Under the stripe lock a reader observed the writer's + value at most **~110–210 µs old** (max over ~170k locked reads; that is lock-wait plus + scheduler latency, not a memory-visibility delay). +- **E2/E3 — design correction:** *a 16-byte zval is not atomic.* The value word and the + `u1.type_info` word are two separate stores, and an unlocked reader observed the two + halves from different generations **116 039 times in 8.6 M reads (~1.3 %)**. At the PHP + level a 3-property update was observed half-applied in **2.7–3.8 %** of unlocked reads. + Readers **must take the same stripe lock as the writer** whenever the *type* can change + or more than one slot participates. This lands directly on E3's "16-byte tagged record" + contract: a `SharedChannel` ring slot **cannot** be published with a plain store of the + record — publish the payload first, then the tag, with the tag store as the release point, + or keep the whole ring operation under the ring mutex (recommended for v1). + +--- + +## S13 — pre-sized arData in shared memory · **GREEN (with a hard trap, documented)** + +A 64-entry hash `zend_array` was built with `PersistentHashTable`, sealed with +`markImmutable()`, and relocated into the arena — struct **and** the single engine data +block, with `arData` re-pointed: + +``` +nTableMask=-128 HT_HASH_SIZE=512 HT_DATA_SIZE=2048 one block of 2560 bytes +arena table: struct at ...400, block at ...1000, arData at ...1200 (inside arena: true) +zval type_info = 0x7 (GC_IMMUTABLE => non-refcounted IS_ARRAY) +``` + +The relocation arithmetic implementers need (mirrors `zend_types.h`): + +``` +HT_HASH_SIZE(nTableMask) = (uint32_t)(-(int32_t)nTableMask) * sizeof(uint32_t) +HT_DATA_SIZE(nTableSize) = nTableSize * sizeof(Bucket) // 32 bytes +HT_GET_DATA_ADDR(ht) = (char*)ht->arData - HT_HASH_SIZE(ht->nTableMask) +``` +`nTableMask` is declared `uint32_t` but is used signed — read it signed or the hash size +comes out astronomically wrong. + +### Evidence + +- `count()` = 64, `$a['k7']` = 70, `array_sum()` over `foreach` = 20160, and z-engine's own + `HashTable` view agrees — all through a real PHP array zval pointing at arena memory. +- Concurrent load: one child overwrote `bucket[7].val` in place (raw `lval` + `IS_LONG`) + 200 000 times under the mutex while two siblings read. + ``` + value reader: 157828 locked reads, 0 anomalies, highest 200000 of 200000 + structural reader: 78054 full foreach+count walks, 0 anomalies + ``` +- **The trap.** A child inserted past capacity into a *non-sealed* arena table: + ``` + free(): invalid pointer + growth child: killed by signal 6 (SIGABRT) + parent now reads ht->arData = 0x5575f5e9b1c0 (inside arena2: false) + post-growth foreach child: exit 0 + it walked 8 entries summing to 280; the shared struct claims nNumOfElements=8 nTableSize=16 + ``` + Two distinct failures in one event: (1) the resize `pefree()`s the *old* block, which is + arena memory the process allocator never handed out → **SIGABRT**; (2) before aborting the + engine had already written the new `arData` — a pointer into that child's private heap — + **into the shared struct**, so a surviving sibling walks a table that looks perfectly + healthy and returns **silent garbage, with no signal at all**. + +### Consequences + +- **E1 (#16):** the "registry tables never grow via the engine" guard is not a nicety, it is + the difference between a crash and silent corruption. Guard shape that works: record + `arData` at seal time and assert on every access that `HT_GET_DATA_ADDR(ht)` is still + inside the arena bounds — the pointer change is cheap to detect and is the *only* + observable symptom in the silent case. Pre-size with the Z1 external-arData API and never + hand a growable table to userland. +- **E2 (#17):** confirms "plain-array property mutation stays forbidden". In-place *value* + overwrite of an existing bucket is safe and fast; anything that can trigger + `zend_hash_do_resize` (insert, `zend_hash_add`, packed→hash conversion) is not. +- **E3 (#18):** `SharedArray` as a **fixed-capacity vector of 16-byte records** rather than a + wrapped `zend_array` is the right call; this spike is the evidence for why. +- Bucket **keys** in a relocated table still point at malloc-interned `zend_string`s. That + survives fork by COW but would not survive a non-forked attach — arena-intern keys too + (S16 shows the mechanics). + +--- + +## S14 — per-process side effects of attach · **RED for the current field layout; the side table is mandatory** + +### A. `obj->handle` is clobbered + +``` +parent put() -> handle 35, obj->handle=35, spl_object_id=35 +child 0: put() returned handle 36 ... child 1: put() returned handle 36 +parent afterwards: obj->handle=36, spl_object_id($parentInstance)=36 (parent's real slot is 35) +parent's object store slot 36 currently holds: a DIFFERENT live object +``` + +Note the detail that makes this worse than a race: both children were handed **the same +handle number 36**, because each inherited the same COW'd `EG(objects_store).free_list_head`. +Handles are not merely clobbered, they *collide by construction*. After the children ran, +the parent's `spl_object_id()` returns a slot number belonging to someone else, and +`ObjectStore::recycle()` at detach would push a **foreign** slot onto the free list. + +### B. `obj->properties` — the dynamic-properties pointer hazard + +``` +inside child A: properties 0x0 -> get_object_vars(2 vars) -> 0x7f81c76c4310 + -> var_dump -> same -> json_encode -> same -> (array) cast -> same +PARENT now reads obj->properties = 0x7f81c76c4310 (child A is gone; that is child A's private heap) +``` + +A single `get_object_vars()` — an operation that reads like a pure read — writes a +request-heap `HashTable*` into the shared struct, and it stays there. Two follow-ups: + +- a **forked sibling** survived but got the wrong answer: `get_object_vars()` returned + **1 property instead of 2**, `var_dump()` printed a 49-byte dump. Silent garbage, no signal + — because fork gave it the same COW heap layout, so the address happened to be mapped. +- a process that did **not** inherit that heap (simulated by pointing `properties` at an + address mapped nowhere) died with **SIGSEGV on both 8.4 and 8.5**. + +### C. `obj->ce` and `obj->handlers` + +``` +parent: std_object_handlers=0x556beb7b5920, S14Holder ce=0x7fec5b604018 +child 0: handlers=SAME S14Holder ce=SAME post-fork S14LateClass ce=0x7fec5747d368 +child 1: handlers=SAME S14Holder ce=SAME post-fork S14LateClass ce=0x7fec57476928 +``` + +`std_object_handlers` is address-identical in every forked process — **safe to keep inside +the shared struct**, exactly as E2 assumes. A class entry loaded **before** the fork is also +address-identical. But a class first declared **after** the fork lands at a different address +in each process as soon as their compile histories differ (child 0 declared 40 decoy classes +first). So `ce` is fork-stable **only** for the pre-fork-loaded subset. + +### Consequences + +- **E2 (#17), item 2 — confirmed necessary and correctly scoped.** `handle`, `ce`, + `properties` out of the shared struct into a per-process side table keyed by arena address; + `handlers` stays. Add these concrete requirements: + - **`properties` must be forced back to `NULL` in the shared struct** on every attach, and + the object must be barred from ever caching a rebuilt bag there. The cheapest correct + shape is a `get_properties_for`/`get_debug_info` handler pair on the shared class that + builds a *request-local* table and never writes `obj->properties` — otherwise the hazard + is re-armed by the first `var_dump()` any worker ever runs. A "children never write the + shared struct" rule is **not** enough here: the write is performed by engine C code + inside `zend_std_get_properties`, not by our code. + - The full trigger list observed: `get_object_vars()`, `var_dump()`, `json_encode()`, + `(array)` cast. Also reachable via `serialize()`, `debug_zval_dump()`, + `ReflectionObject::getProperties()`, object iteration, `property_exists()` on dyn props. + - `spl_object_id()` reads `obj->handle` **directly** — it cannot be fixed by a side table + alone. If per-process identity matters to user code, the shared class needs its own + identity story (document it, or expose `Arena::idOf($obj)`), because + `spl_object_id()`/`spl_object_hash()` on a shared object will be whatever the last + attaching process wrote. + - `attach()` must **not** call `zend_objects_store_put` on a struct another process may be + attaching concurrently. Either serialize attach under the object's stripe lock and + immediately restore the field from the side table, or (better) stop letting the engine + write it at all: `put()` then rewrite `obj->handle` back to a sentinel and serve the real + handle from the side table. +- **E1 (#16):** the registry must key everything by **arena address**, never by handle — + handles are not stable, not unique, and not even distinct between processes. + +--- + +## S16 — string swap visibility · **GREEN** + +Two `zend_string`s were interned into the arena (via +`StringEntry::persistentInterned()` + memcpy — the `GC_IMMUTABLE|IS_STR_INTERNED` header is +already the shape a shared string needs: engine copies it into zvals without refcounting and +copy-on-writes on mutation, so no process ever bumps a refcount or frees it in shared +memory). A shared object's `string $name` slot was pointed at string A, then swapped +300 000 times between A and B. + +``` +LOCKED reader: 267587 reads (A=135833 B=131754), 0 torn, max staleness 205.7 us +UNLOCKED reader: 2022502 reads, 0 torn pointers, 0 unexpected string values, + max staleness 207.8 us +``` +(8.5: 251818 / 0 torn, 2 616 769 unlocked reads / 0 torn.) + +Control: the same swap on an 8-byte slot **straddling a 4 KiB page boundary** produced no +tearing on this CPU over 1.5–2.8 M reads either — the ISA still gives no guarantee there, so +the alignment invariant stays, it just isn't cheaply falsifiable on this hardware. + +### Consequences + +- **E2 (#17):** the "string property = arena-intern new bytes + pointer swap under lock" + contract is sound. Concretely: **a naturally-aligned 8-byte pointer swap never tears**, so + the lock is buying *ordering between slots* and *lifetime safety*, not per-pointer + atomicity. A single-string-slot reader may legitimately read without the lock and will get + either the old or the new string, never a mix — useful for hot read paths, and worth + stating explicitly so nobody adds locking that isn't needed. +- Stale reads without the lock are bounded by the writer's publish rate, not by anything + architectural: the maximum age of the value an unlocked reader saw was **~208 µs**, + statistically identical to the locked reader's. Taking the lock does **not** make a reader + fresher; it makes a *multi-slot* read consistent. +- Lifetime rule the numbers imply: the swapped-away string must **not** be freed. With + leak-until-teardown v1 that is automatic; if a reclaimer ever appears, an unlocked reader + holding the old pointer is the hazard to design against. +- Keep every arena `zval` 8-byte aligned at minimum (the natural `zend_object` layout gives + `properties_table[i]` at `40 + 16i`, which is 8-aligned when the object block is + 16-aligned — bump-allocate objects 16-aligned and this is free). + +--- + +## S17 — closures across fork · **GREEN for Phase A, AMBER for Phase B** + +### (a) Pre-fork closures — safe + +Static closure, closure with `use` scalars, and a `$this`-bound closure, each invoked +100 000 times concurrently in two children: + +``` +child 0: 0 wrong results, checksum 55100050000, closure spl_object_id 24 +child 1: 0 wrong results, checksum 55100050000, closure spl_object_id 24 +``` + +### (b) Post-fork closure invoked by a sibling — unsafe, both failure modes captured + +Child A allocated ballast, built a closure, and sent its address down a pipe; child B +materialized an `IS_OBJECT` zval at that address and invoked it. + +- **8.4:** `child B: killed by signal 11 (SIGSEGV)` — markers show it got as far as + `$alien instanceof \Closure === true` and died inside the invoke. +- **8.5:** child B invoked **a completely different function** — the address held the spike's + own fork-body closure, which ran with its captured variables `null` and died with + `TypeError: spl_object_id(): Argument #1 must be of type object, null given`. + +The 8.5 outcome is the more instructive one: the address *was* a live `Closure` in child B, +just not the intended one. There is no validity check that could have caught it. + +### (c) Pointer inventory of a `zend_closure` + +For a trivial `function (int $x) use ($base) { static $calls = 0; ... }`, **14 of 20** +pointer fields are non-NULL on 8.4 (12 on 8.5): + +| field | 8.4 | note | +|---|---|---| +| `std.ce` / `std.handlers` | set | process-stable (Closure is an internal class) | +| `op_array.opcodes` | set | 10 ops × 32 B = 320 B | +| `op_array.literals` | set | 1 × 16 B (NULL on 8.5 for this closure) | +| `op_array.vars` | set | 3 × 8 B | +| `op_array.arg_info` | set | 1 × 32 B | +| `op_array.function_name`, `.filename` | set | `zend_string*` | +| `op_array.refcount` | set (8.4) / NULL (8.5) | shared op_array refcount | +| `op_array.live_range` | set | | +| `op_array.static_variables` | set | a `HashTable*` | +| `op_array.static_variables_ptr__ptr` | set | **per-request slot** | +| `op_array.run_time_cache__ptr` | set | **per-request slot** | +| `op_array.scope`, `.attributes`, `.dynamic_func_defs`, `.try_catch_array` | NULL | for this closure | +| `this_ptr`, `called_scope` | NULL | set for bound closures | + +A pre-fork closure keeps **identical addresses** in the child (verified: +struct, `opcodes`, `literals`, `static_variables` all match). + +### Consequences + +- **E5 (#20) Phase A — GREEN, ship it.** Pre-fork closures are safe to persist by address and + to transport as `OBJ` records. The op_array, literals and captured statics are read-only + COW data; `run_time_cache` is per-process private after fork (each child COW-copies its own + page on first write), which is exactly why (a) is correct. +- **E5 Phase B — AMBER, and the blocker is not the op_array bytes.** Deep-cloning the + *compiled* graph is small and tractable: ~400 bytes for the closure above, and every field + is enumerable through z-engine. The blocker is that **`run_time_cache__ptr` and + `static_variables_ptr__ptr` point into the request arena, not into the op_array**. Put a + closure struct in the arena and those two slots become *shared*, so two processes would + write each other's polymorphic-cache entries and each other's `static` variables. Any + Phase B design must re-mint both per process — which is a per-process side table for + closures, structurally the same mechanism E2 builds for objects. Recommend: implement + Phase A now, and scope Phase B as "arena-clone the immutable compiled graph + per-process + cache/statics side table", or record the documented not-soundly-achievable verdict that + #20's acceptance criteria already allow for. +- **E3 (#18):** the typed rejection for post-fork closures must be **unconditional and + address-based** (was this closure compiled before the fork barrier?). It must not be a + "does this look like a Closure" check — S17(b) on 8.5 shows a wrong address passing every + plausible validity test and then executing the wrong function. + +--- + +## S8 / S15 — quick confirmations (X1 owns the in-repo versions) · **GREEN** + +### S8 — robust pshared mutex, owner died + +``` +holder: killed by signal 9 (SIGKILL) +parent pthread_mutex_lock() returned 130 after 6.5 us (EOWNERDEAD == 130) +consistent()=0 unlock()=0 then lock()=0 unlock()=0 +``` + +Two controls, both of which the implementation must encode as rules: + +- **skipping `pthread_mutex_consistent()` is fatal and permanent**: unlock without it and the + next `lock()` returns **131 = ENOTRECOVERABLE**, forever, for every process. A missed + recovery handler takes the whole arena down, not just one critical section. +- a **non-robust** pshared mutex whose owner dies is simply stuck: `trylock()` returns + **16 = EBUSY** and `lock()` would block forever. `PTHREAD_MUTEX_ROBUST` is not optional. + +Layout confirmed as assumed: glibc x86-64 `pthread_mutex_t` = 40 bytes, +`pthread_mutexattr_t` = 4; the spikes use 64-byte slots (one cache line) and that works +cleanly with `FFI::cdef(..., null)` resolving libc through the process image. + +### S15 — concurrent bump allocation, 4 children + +``` +S15a (under the mutex): 100000 records, 11199712 bytes carved, + 0 overlaps, 0 duplicate offsets, 0 corrupted blocks +S15b (no mutex): 82309 records (expected 100000), 2031 overlaps, + 765 duplicate offsets, 3124 corrupted blocks +``` + +Verification is not just interval arithmetic: every block was `memset` with its owner's tag +and re-read afterwards, so an overlap shows up as *content* corruption too. + +### Consequences + +- **E1 (#16):** the stripe-mutex bank design is sound. Add to the acceptance criteria: + **every `pthread_mutex_lock()` call site must handle `EOWNERDEAD`** — check the return + code, run the invariant repair for that stripe, call `pthread_mutex_consistent()`, and only + then proceed. A wrapper that ignores the return value is a latent arena-wide deadlock. It + is worth a debug assertion that no lock helper discards its `int` result. +- Because a worker can die mid-critical-section, the E2 rule "critical sections are memcpys + and pointer swaps only, no engine calls that allocate, no user callbacks, no Fiber + suspension" is what makes `EOWNERDEAD` recovery *possible at all*: a section that can only + be half-done in a bounded, structurally checkable way is one you can repair. Keep that rule + enforced by assertion, as #17 already plans. + +--- + +## Consolidated design corrections for the implementing agents + +1. **A 16-byte zval is not atomic.** Value and `type_info` are separate stores; ~1.3 % of + unlocked reads observed mismatched halves over 8.6 M samples. Type-changing writes and + multi-slot updates require the stripe lock on **both** sides. (E2, E3 tagged records.) +2. **An aligned 8-byte pointer swap is atomic.** Single-slot string/object-reference reads + may skip the lock; they get old-or-new, never a mix. Don't over-lock hot read paths. (E2) +3. **`obj->properties` is written by engine C code on read-shaped operations.** A + "we never write it" policy cannot hold it. Force it `NULL` and intercept + `get_properties_for`/`get_debug_info` on the shared class, or a single `var_dump()` in one + worker segfaults the next one. Confirmed SIGSEGV on 8.4 and 8.5. (E2) +4. **`obj->handle` collides, it does not merely race.** Forked children inherit the same + object-store free list and hand out the *same* handle. `spl_object_id()` on a shared + object is unreliable by construction — decide and document the identity story. (E2) +5. **`obj->ce` is fork-stable only for pre-fork-loaded classes.** Two workers that autoload + in different orders place the same class at different addresses. Side-table it; keep only + `handlers` in the shared struct. (E2) +6. **Table growth is silent, not loud.** A resize writes a private-heap `arData` into the + shared struct *before* it aborts; siblings then read plausible garbage with no signal. + Bounds-check `HT_GET_DATA_ADDR(ht)` against the arena on access — the pointer change is + the only observable symptom. (E1, E3) +7. **`EOWNERDEAD` must be handled at every lock site.** Skipping `pthread_mutex_consistent()` + poisons the mutex with `ENOTRECOVERABLE` permanently, arena-wide. (E1) +8. **Post-fork closures cannot be validated by inspection.** On 8.5 a stale address held a + *different, perfectly valid* `Closure` and executed it. Reject on provenance (compiled + before the fork barrier?), never on shape. (E5, E3) +9. **Phase B's real cost is not the op_array.** ~400 bytes of enumerable compiled data; the + blocker is `run_time_cache__ptr` and `static_variables_ptr__ptr` pointing into the request + arena. Arena-resident closures share those per-request slots between processes — they must + be re-minted per process. (E5) From ee16409634074ba8ef08a20220c7e519365fa11e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:26:45 +0000 Subject: [PATCH 06/27] chore: let CI resolve the z-engine seam branch when the sibling checkout is absent The path repository only exists in a workspace that has both repos checked out side by side; a VCS entry behind it lets CI resolve the same branch from GitHub. Both go away together with the pin once lisachenko/z-engine#223 merges and the constraint returns to "8.4.x-dev || 8.5.x-dev". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index 95241d3..8e0cd21 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,10 @@ "options": { "symlink": true } + }, + { + "type": "vcs", + "url": "https://github.com/lisachenko/z-engine" } ] } From e3b02e7e3cd601d2ae13c94d6dc1f441a94c374f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:28:34 +0000 Subject: [PATCH 07/27] feat(shm): verify the arena header before a recovering worker trusts an offset The magic word and the layout version were written but never read. A worker that recovers an inherited mapping is exactly who should check them: the magic proves the region is an arena at all, the version proves the mutex bank and the roots directory sit where this build expects them - and locking bytes at a wrong offset would be locking somebody else's data. bootShared() calls it on the recovery path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/PersistentStore.php | 4 ++++ src/Shm/Arena.php | 23 +++++++++++++++++++++++ tests/Shm/ArenaTest.php | 11 +++++++++++ 3 files changed, 38 insertions(+) diff --git a/src/PersistentStore.php b/src/PersistentStore.php index 0cbd9e5..5e986ce 100644 --- a/src/PersistentStore.php +++ b/src/PersistentStore.php @@ -218,6 +218,10 @@ public static function bootShared( 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); } diff --git a/src/Shm/Arena.php b/src/Shm/Arena.php index 1c138c8..df88b57 100644 --- a/src/Shm/Arena.php +++ b/src/Shm/Arena.php @@ -501,6 +501,29 @@ public function unlockStripe(int $index): void Libc::unlockMutex($this->stripeAt($index), $index); } + /** + * 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 * diff --git a/tests/Shm/ArenaTest.php b/tests/Shm/ArenaTest.php index b5e670d..dfc0624 100644 --- a/tests/Shm/ArenaTest.php +++ b/tests/Shm/ArenaTest.php @@ -44,6 +44,17 @@ public function testFreshArenaStartsEmptyAboveItsHeader(): void $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(); From de6705b8169b4fa0991c507fc1861bbb95da5352 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:46:01 +0000 Subject: [PATCH 08/27] fix(ci): resolve z-engine via vcs only so composer install works without a sibling checkout Composer hard-fails when a path repository's url does not exist; CI runners have no ../z-engine checkout, so every job died before installing. The vcs entry alone resolves the same seam branch everywhere. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- composer.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/composer.json b/composer.json index 8e0cd21..7babb5d 100644 --- a/composer.json +++ b/composer.json @@ -33,13 +33,6 @@ "minimum-stability": "dev", "prefer-stable": true, "repositories": [ - { - "type": "path", - "url": "../z-engine", - "options": { - "symlink": true - } - }, { "type": "vcs", "url": "https://github.com/lisachenko/z-engine" From da16d3bdafdcb72264fe7eaf74230cb1483152c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:21:10 +0000 Subject: [PATCH 09/27] feat(shm): dedicated arena mutexes and address-hashed stripe selection The bank of 62 consumer stripes is the right shape for many small structures sharing a few locks and the wrong shape for a structure whose lock is taken on every operation. allocateMutex() reserves a robust process-shared mutex of its own inside the payload, initialized once by the creating process and found again through the owning structure's header, so two busy channels never serialize against each other. stripeFor() keeps the bank useful for everything else by hashing an address to a stripe, dropping the bits every arena block shares. tryLockMutex() gained the EOWNERDEAD answer it was swallowing: acquiring a lock a died owner left behind and learning that it happened are two halves of one result, and a caller that guards multi-word state needs both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/Shm/Arena.php | 108 +++++++++++++++++++++++++++++++++++-- src/Shm/ArenaException.php | 2 +- src/Shm/Libc.php | 18 ++++--- 3 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/Shm/Arena.php b/src/Shm/Arena.php index df88b57..20aafd1 100644 --- a/src/Shm/Arena.php +++ b/src/Shm/Arena.php @@ -92,6 +92,14 @@ final class Arena 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) @@ -153,6 +161,13 @@ final class Arena */ 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( @@ -489,11 +504,13 @@ public function lockStripe(int $index): bool } /** + * @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 + public function tryLockStripe(int $index, ?bool &$recovered = null): bool { - return Libc::tryLockMutex($this->stripeAt($index), $index); + return Libc::tryLockMutex($this->stripeAt($index), $index, $recovered); } public function unlockStripe(int $index): void @@ -501,6 +518,68 @@ 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 * @@ -606,8 +685,9 @@ public function destroy(): void if ($this->released || !$this->isCreator()) { return; } - $this->released = true; - $this->mutexes = []; + $this->released = true; + $this->mutexes = []; + $this->ownedMutexes = []; Libc::unmap($this->base, $this->size); } @@ -642,6 +722,26 @@ 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(); diff --git a/src/Shm/ArenaException.php b/src/Shm/ArenaException.php index 7557e20..59e65fd 100644 --- a/src/Shm/ArenaException.php +++ b/src/Shm/ArenaException.php @@ -127,7 +127,7 @@ public static function invalidMutexIndex(int $index): self public static function mutexOperationFailed(string $operation, int $index, int $errorCode): self { return new self(sprintf( - '%s on arena mutex %d failed with error %d; the shared lock state is unusable', + '%s on the arena mutex at slot/address %d failed with error %d; the shared lock state is unusable', $operation, $index, $errorCode, diff --git a/src/Shm/Libc.php b/src/Shm/Libc.php index 269f59c..b013c89 100644 --- a/src/Shm/Libc.php +++ b/src/Shm/Libc.php @@ -260,20 +260,26 @@ public static function lockMutex(CData $mutex, int $index): bool } /** + * @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 + public static function tryLockMutex(CData $mutex, int $index, ?bool &$recovered = null): bool { - $ffi = self::ffi(); - $code = $ffi->pthread_mutex_trylock($mutex); + $recovered = false; + $ffi = self::ffi(); + $code = $ffi->pthread_mutex_trylock($mutex); if ($code === self::EBUSY) { return false; } if ($code === self::EOWNERDEAD) { - $recovered = $ffi->pthread_mutex_consistent($mutex); - if ($recovered !== 0) { - throw ArenaException::mutexOperationFailed('pthread_mutex_consistent', $index, $recovered); + $consistent = $ffi->pthread_mutex_consistent($mutex); + if ($consistent !== 0) { + throw ArenaException::mutexOperationFailed('pthread_mutex_consistent', $index, $consistent); } + $recovered = true; return true; } From 047a86553577bf9db54617f8e43942a851e0b0cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:21:22 +0000 Subject: [PATCH 10/27] feat(ipc): 16-byte tagged value records and a codec that never encodes A value crossing a worker boundary is one of nine shapes and nothing else: three carry no payload, two carry the value inline, three carry an arena address, one is the channel control tag. Strings are interned into the arena at send (a structural memcpy, not a serialization) and materialize on the far side as a non-refcounted zval over the same block; shared objects and shared arrays contribute nothing but their address. The refusals matter as much as the round trips. A plain array, a resource, a closure or an object this family does not share has no address-shaped form, and the only alternative would be to encode it - so each is refused with a message naming the remedy instead. Closures are rejected on PROVENANCE rather than shape: a stale post-fork address was observed holding a valid Closure of a different function, so inspecting one can never establish that sharing it is safe. PersistentStore::addressOfInstance() is the predicate behind the object tag: it answers with an address only for objects this registry shares, keyed by arena address because forked children hand out identical object handles. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/Ipc/NotShareableValueException.php | 102 ++++++++++++ src/Ipc/ValueCodec.php | 212 +++++++++++++++++++++++++ src/Ipc/ValueRecord.php | 73 +++++++++ src/Ipc/ValueTag.php | 75 +++++++++ src/PersistentStore.php | 49 ++++++ 5 files changed, 511 insertions(+) create mode 100644 src/Ipc/NotShareableValueException.php create mode 100644 src/Ipc/ValueCodec.php create mode 100644 src/Ipc/ValueRecord.php create mode 100644 src/Ipc/ValueTag.php 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/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/PersistentStore.php b/src/PersistentStore.php index 5e986ce..6040cba 100644 --- a/src/PersistentStore.php +++ b/src/PersistentStore.php @@ -232,6 +232,30 @@ public static function bootShared( 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) * @@ -415,6 +439,31 @@ public function addressOf(string $className): ?int 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 * From f664ce36bdbf4214518c7a2279f0de9119fb14a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:21:32 +0000 Subject: [PATCH 11/27] feat(ipc): notification plane of fixed event records over inherited sockets Shared memory can hold state but cannot wake anybody, and FFI offers no futex and no condition variable. So blocking uses the one thing PHP can select() on - a descriptor - while every value stays in the arena. A sender that makes a ring non-empty or settles a slot writes ONE 16-byte event record to each parked process; the receiver drains it and re-reads the shared state. Descriptors are per-process, so the pairs are minted before any worker forks and inherited; the arena half of the registry is only the claim table saying which pid owns which slot, with dead owners recycled so a supervisor may respawn forever. Writes go through a single choke point that accepts nothing but an event record, and observeWrites() exposes it so a test can prove the sockets carry no values rather than asserting it in prose. Waiter tables are registered under the owning structure's lock and read without it: registering and re-checking the state in one critical section is what makes a lost wakeup impossible, while a spurious one costs a re-poll. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/Ipc/IpcException.php | 103 ++++++++++ src/Ipc/WaiterTable.php | 98 ++++++++++ src/Ipc/WakeEvent.php | 87 +++++++++ src/Ipc/WakeOpcode.php | 44 +++++ src/Ipc/WakeRegistry.php | 393 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 725 insertions(+) create mode 100644 src/Ipc/IpcException.php create mode 100644 src/Ipc/WaiterTable.php create mode 100644 src/Ipc/WakeEvent.php create mode 100644 src/Ipc/WakeOpcode.php create mode 100644 src/Ipc/WakeRegistry.php 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/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); + } +} From cf616195a3ed27ffec361e122e3a296bd079fac9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:21:48 +0000 Subject: [PATCH 12/27] feat(ipc): shared channel with rendezvous handoff and cross-process close The ring, its head and tail counters, the closed flag and both waiter tables are arena memory, so a producer in one worker and a consumer in another operate on one structure instead of two views. Counters are monotonic rather than wrapped indexes: the fill level is a subtraction, the slot is a modulo, and a sender that deposited at ticket N knows its record was taken the moment head passes N - which makes the capacity-0 rendezvous a single word comparison instead of a state machine. The whole ring operation stays under one dedicated robust mutex: a 16-byte record store is not atomic, and publishing payload-then-tag would still leave the counters racing. Inside the critical section there is nothing but aligned word access - values are encoded before the lock and decoded after it, and waiters are notified once it is gone. Closing is shared state, so a producer learns about a consumer's close() the next time it takes the lock: receivers drain what is buffered and then see the end of stream, senders get a typed refusal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/Ipc/ClosedChannelException.php | 45 +++ src/Ipc/SharedChannel.php | 568 +++++++++++++++++++++++++++++ 2 files changed, 613 insertions(+) create mode 100644 src/Ipc/ClosedChannelException.php create mode 100644 src/Ipc/SharedChannel.php 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/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); + } +} From 4a8538f1c1a7b7b582d95bfb8fe86732f87d06d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:21:48 +0000 Subject: [PATCH 13/27] feat(ipc): shared array, mutex, atomic cell and wait group in the arena SharedArray is the container a plain PHP array cannot be: growth would reallocate the bucket block into one worker's private heap - and the engine writes that private pointer into the shared struct before it aborts - so capacity is decided at creation and an index outside it is a typed error. Both halves of an element access take the instance stripe, because reading a tag and a payload together is exactly the two-word read that was measured to tear. SharedMutex gives userland the same lock with the policy a caller needs around it: acquisition is a trylock loop with backoff, since a process blocked in libc cannot run its scheduler or answer its supervisor, and a lock inherited from a died owner is made consistent immediately and REPORTED rather than swallowed. AtomicInt rides the one hardware guarantee available - an aligned 8-byte load or store never tears - for get/set, and takes a stripe for read-modify-write, which FFI cannot express any other way. SharedWaitGroup adds a waiter table on top and refuses to clamp a negative counter: a done() without an add() is a miscount the whole family should see. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/Ipc/AtomicInt.php | 148 ++++++++++++++++++++++ src/Ipc/SharedArray.php | 237 +++++++++++++++++++++++++++++++++++ src/Ipc/SharedMutex.php | 163 ++++++++++++++++++++++++ src/Ipc/SharedWaitGroup.php | 242 ++++++++++++++++++++++++++++++++++++ 4 files changed, 790 insertions(+) create mode 100644 src/Ipc/AtomicInt.php create mode 100644 src/Ipc/SharedArray.php create mode 100644 src/Ipc/SharedMutex.php create mode 100644 src/Ipc/SharedWaitGroup.php 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/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/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)); + } +} From 42f71ad958fd39e75960c256cf23b2842bc226c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:21:59 +0000 Subject: [PATCH 14/27] feat(ipc): result slots carrying coroutine returns and panics by address Futures over the shared area, and the piece the epic's runtime model rests 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 is told with a fixed event record and reads the value straight out of shared memory. The socket never carries the value - an INT or FLOAT event has a zero where an address would be. The same table hands spawn arguments downwards under the identical contract. A slot settles exactly once, which is what makes "see the state, then read the record" a sequence rather than a race, and slots are handed out by a bump counter over a table pre-sized before the fork. A Throwable can never be shared - internal C state, live frames, a chained previous - and encoding it is exactly what this package refuses to do, so the panic path persists a plain three-string object into the arena and the slot carries its address. Capturing returns that address rather than the instance: a store refuses to release a graph the request can still reach, and holding the error object would make the next capture fail. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/Ipc/ResultSlotTable.php | 353 ++++++++++++++++++++++++++++++++++++ src/Ipc/ResultState.php | 36 ++++ src/Ipc/SharedError.php | 68 +++++++ src/Ipc/SlotResult.php | 47 +++++ 4 files changed, 504 insertions(+) create mode 100644 src/Ipc/ResultSlotTable.php create mode 100644 src/Ipc/ResultState.php create mode 100644 src/Ipc/SharedError.php create mode 100644 src/Ipc/SlotResult.php 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/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/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; + } +} From 584170c857394f122b695db79098f11091e9e309 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:22:10 +0000 Subject: [PATCH 15/27] fix(store): report an arena-backed module through its live store, not its anchor A module's globals[0] means two different things - a registry hashtable in the default mode, the ARENA BASE in arena mode - and phpinfo() walks every registered module, so a worker running both modes read the arena header as a hashtable and died with SIGSEGV rather than an exception. Reproduced by running the IPC suite before the store suite in one process; the crash predates that suite and needed only the ordering to surface. The state is now reported through the store booted for that module, which is the only thing that knows which registry it holds and how it was built. Without one, the anchor is checked against the arena magic before anything is interpreted: one aligned load tells an arena from a registry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/ObjectPersistenceModule.php | 24 ++++++++++++- tests/Shm/ArenaModuleInfoTest.php | 59 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/Shm/ArenaModuleInfoTest.php diff --git a/src/ObjectPersistenceModule.php b/src/ObjectPersistenceModule.php index 5fa2cce..7d5f3bf 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; @@ -106,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(); @@ -121,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/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'); + } +} From bd549ba884ad1b0ae9db92fae685a67913bad3ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:22:23 +0000 Subject: [PATCH 16/27] test(tests): fork-based coverage for channels, slots and the notification plane Real processes throughout: a producer child and a consumer child agreeing on FIFO order through one ring, a rendezvous sender that must still be parked 400 ms later, a close() issued by one process and drained by another, four children summing into one atomic cell, a worker SIGKILLed inside a critical section and the lock recovered afterwards, a child completing a result slot of every tag kind while the parent is parked on its socket - including an object the child minted after the fork, which the parent then holds at the very same address. Two claims are tested rather than asserted. The sockets are wrapped at their one write point and every byte that crossed is parsed back as a fixed event record, with value bytes searched for and absent. And the Never-Serialize Rule gets a guard with teeth: namespace-local shadows of serialize, igbinary and JSON in the three namespaces the data path runs through, proven to intercept a call before the round trip is measured at zero. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- tests/Ipc/IpcTestCase.php | 204 ++++++++++++++++++ tests/Ipc/NotificationPlaneForkTest.php | 201 +++++++++++++++++ tests/Ipc/ResultSlotForkTest.php | 229 ++++++++++++++++++++ tests/Ipc/SerializationGuard.php | 61 ++++++ tests/Ipc/SharedChannelForkTest.php | 276 ++++++++++++++++++++++++ tests/Ipc/SharedStructuresForkTest.php | 248 +++++++++++++++++++++ tests/Ipc/ValueCodecTest.php | 156 ++++++++++++++ tests/Ipc/serialization-guard.php | 154 +++++++++++++ 8 files changed, 1529 insertions(+) create mode 100644 tests/Ipc/IpcTestCase.php create mode 100644 tests/Ipc/NotificationPlaneForkTest.php create mode 100644 tests/Ipc/ResultSlotForkTest.php create mode 100644 tests/Ipc/SerializationGuard.php create mode 100644 tests/Ipc/SharedChannelForkTest.php create mode 100644 tests/Ipc/SharedStructuresForkTest.php create mode 100644 tests/Ipc/ValueCodecTest.php create mode 100644 tests/Ipc/serialization-guard.php 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); + } +} From 8d3ef0ba24f80e7510b865e721f82a3ad0aca288 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:22:24 +0000 Subject: [PATCH 17/27] docs: describe the IPC primitives and what the sockets are allowed to carry Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/README.md b/README.md index 3dc4c68..018b7b2 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,65 @@ Reader/writer contract for anything you build on the arena directly: a naturally mutex as the writer whenever a value's *type* can change or more than one slot participates. The evidence for every claim in this section is in `spikes/`. +### 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 @@ -315,6 +374,18 @@ $store->addressOf(User::class): ?int; // the eight bytes that travel betwee $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 From ee9c99588aaeeb1464a283abebebdc17c9e19c15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:29:11 +0000 Subject: [PATCH 18/27] fix(composer): track the z-engine release lines now that the seam is merged into 8.4 The allocator seam landed on z-engine's 8.4 branch, and the branch this package was pinned to has since been restarted from master for the 8.5 merge-up - so the pin now resolves an 8.5-only line and every PHP 8.4 leg fails to install. The constraint returns to one dev line per supported minor, which is where it belongs: Composer picks the line matching the running interpreter, and 8.4 already carries the seam this package builds on. The vcs repository entry stays (it is what makes the branches resolvable); no path repository is reintroduced. Verified against the resolved 8.4 line: suite green (128 tests), both soaks pass. The 8.5 legs stay red until the merge-up into master lands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7babb5d..67df89f 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Shared data extension for PHP: persistent memory and request-surviving objects", "type": "library", "require": { - "lisachenko/z-engine": "dev-claude/php-coroutines-plan-5vovsz", + "lisachenko/z-engine": "8.4.x-dev || 8.5.x-dev", "php": "^8.4", "ext-ffi": "*" }, From eb56874ece17d53bedeee72e798d3cc1dbd4a4c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:38:17 +0000 Subject: [PATCH 19/27] chore(spikes): drop the vendored validation sweep with environment-specific paths The c1/ directory was a copy of the cross-repo validation sweep that established the premise of the epic. It predates this package's arena, so it carried its own bootstrap with paths bound to the machine it ran on - a harness nothing in the repository can run and nothing in the repository needs. The verdicts live on the ticket, and the claims this package actually depends on are promoted to tests rather than to logs. What stays is self-contained: both remaining spikes run against this package's own Arena from the repository root, through Composer's autoloader only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- spikes/README.md | 23 +- spikes/c1/S08_S15_mutex_and_bump.php | 249 ---------- spikes/c1/S12_cross_process_mutation.php | 452 ----------------- spikes/c1/S13_shared_ardata.php | 359 -------------- spikes/c1/S14_attach_side_effects.php | 295 ----------- spikes/c1/S16_string_swap.php | 279 ----------- spikes/c1/S17_closures_across_fork.php | 279 ----------- spikes/c1/lib/bootstrap.php | 348 ------------- spikes/c1/out/S08_S15_mutex_and_bump-8.4.log | 26 - spikes/c1/out/S08_S15_mutex_and_bump-8.5.log | 26 - .../c1/out/S12_cross_process_mutation-8.4.log | 35 -- .../c1/out/S12_cross_process_mutation-8.5.log | 35 -- spikes/c1/out/S13_shared_ardata-8.4.log | 39 -- spikes/c1/out/S13_shared_ardata-8.5.log | 39 -- spikes/c1/out/S14_attach_side_effects-8.4.log | 43 -- spikes/c1/out/S14_attach_side_effects-8.5.log | 43 -- spikes/c1/out/S16_string_swap-8.4.log | 26 - spikes/c1/out/S16_string_swap-8.5.log | 26 - .../c1/out/S17_closures_across_fork-8.4.log | 54 --- .../c1/out/S17_closures_across_fork-8.5.log | 60 --- spikes/c1/run-all.sh | 34 -- spikes/c1/verdicts.md | 459 ------------------ 22 files changed, 13 insertions(+), 3216 deletions(-) delete mode 100644 spikes/c1/S08_S15_mutex_and_bump.php delete mode 100644 spikes/c1/S12_cross_process_mutation.php delete mode 100644 spikes/c1/S13_shared_ardata.php delete mode 100644 spikes/c1/S14_attach_side_effects.php delete mode 100644 spikes/c1/S16_string_swap.php delete mode 100644 spikes/c1/S17_closures_across_fork.php delete mode 100644 spikes/c1/lib/bootstrap.php delete mode 100644 spikes/c1/out/S08_S15_mutex_and_bump-8.4.log delete mode 100644 spikes/c1/out/S08_S15_mutex_and_bump-8.5.log delete mode 100644 spikes/c1/out/S12_cross_process_mutation-8.4.log delete mode 100644 spikes/c1/out/S12_cross_process_mutation-8.5.log delete mode 100644 spikes/c1/out/S13_shared_ardata-8.4.log delete mode 100644 spikes/c1/out/S13_shared_ardata-8.5.log delete mode 100644 spikes/c1/out/S14_attach_side_effects-8.4.log delete mode 100644 spikes/c1/out/S14_attach_side_effects-8.5.log delete mode 100644 spikes/c1/out/S16_string_swap-8.4.log delete mode 100644 spikes/c1/out/S16_string_swap-8.5.log delete mode 100644 spikes/c1/out/S17_closures_across_fork-8.4.log delete mode 100644 spikes/c1/out/S17_closures_across_fork-8.5.log delete mode 100755 spikes/c1/run-all.sh delete mode 100644 spikes/c1/verdicts.md diff --git a/spikes/README.md b/spikes/README.md index 9f6d7fa..e4b7021 100644 --- a/spikes/README.md +++ b/spikes/README.md @@ -4,11 +4,11 @@ Throwaway-by-intent programs, kept because their *answers* are load-bearing. Eve 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, and -their logs are the evidence. +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. +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 @@ -25,11 +25,14 @@ php8.4 -d ffi.enable=1 -d opcache.jit=off spikes/s15-concurrent-bump-allocation. 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. -## Validation spikes (`c1/`) +## The wider validation sweep (not in this repository) -The wider validation sweep that established the premise of EPIC #15, run on PHP 8.4 **and** -8.5 with captured logs in `c1/out/`. `c1/verdicts.md` is the full write-up; the findings that -bind this ticket's implementation: +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 @@ -51,6 +54,6 @@ bind this ticket's implementation: - **S16/S17** — arena-interned strings swap safely under a pointer store; closures are only fork-safe when they existed before the fork (E5). -`c1/run-all.sh` reruns the sweep on both minors; it resolves the 8.5 line of z-engine from a -scratch clone, which is why the 8.5 leg is CI's job rather than something reproducible from -this checkout alone. +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/c1/S08_S15_mutex_and_bump.php b/spikes/c1/S08_S15_mutex_and_bump.php deleted file mode 100644 index 5127e1b..0000000 --- a/spikes/c1/S08_S15_mutex_and_bump.php +++ /dev/null @@ -1,249 +0,0 @@ -pthread_mutex_lock($m); - $flags[0] = 1; // "I hold the lock" - posix_kill(posix_getpid(), SIGKILL); // die holding it - spike_hard_exit(1); -} -while ($flags[0] === 0) { - usleep(1000); -} -$waits = spike_wait([$pid]); -printf(" holder: %s\n", spike_describe_wait($waits)); - -$t0 = hrtime(true); -$rc = $ffi->pthread_mutex_lock($m1); -$lockNs = hrtime(true) - $t0; -printf(" parent pthread_mutex_lock() returned %d after %.1f us (EOWNERDEAD == %d)\n", - $rc, $lockNs / 1000, EOWNERDEAD); -spike_result('S8a lock on an orphaned robust mutex returns EOWNERDEAD (no deadlock)', $rc === EOWNERDEAD); - -$rcC = $ffi->pthread_mutex_consistent($m1); -$rcU = $ffi->pthread_mutex_unlock($m1); -$rc2 = $ffi->pthread_mutex_lock($m1); -$rcU2 = $ffi->pthread_mutex_unlock($m1); -printf(" consistent()=%d unlock()=%d then lock()=%d unlock()=%d\n", $rcC, $rcU, $rc2, $rcU2); -spike_result('S8a pthread_mutex_consistent() restores the mutex', $rcC === 0 && $rc2 === 0); - -// --- control: what happens if consistent() is NOT called ------------------- -spike_step('S8b — CONTROL: recover the EOWNERDEAD without calling consistent()'); - -$m2 = spike_mutex_init($arena + OFF_MUTEX_R2, robust: true); -$flags[1] = 0; -$pid = pcntl_fork(); -if ($pid === 0) { - libc()->pthread_mutex_lock(spike_mutex_at($arena + OFF_MUTEX_R2)); - $flags[1] = 1; - posix_kill(posix_getpid(), SIGKILL); - spike_hard_exit(1); -} -while ($flags[1] === 0) { - usleep(1000); -} -spike_wait([$pid]); - -$rc = $ffi->pthread_mutex_lock($m2); -$ffi->pthread_mutex_unlock($m2); // unlock WITHOUT consistent() -$rcAfter = $ffi->pthread_mutex_lock($m2); -printf(" first lock() = %d, unlock without consistent(), next lock() = %d (ENOTRECOVERABLE == %d)\n", - $rc, $rcAfter, ENOTRECOVERABLE); -spike_result('S8b skipping consistent() poisons the mutex permanently', $rcAfter === ENOTRECOVERABLE, - 'the recovery handler is MANDATORY — a missed consistent() takes the whole arena down'); - -// --- non-robust control ---------------------------------------------------- -spike_step('S8c — CONTROL: a NON-robust pshared mutex whose owner dies'); - -$m3addr = $arena + 192; -$m3 = spike_mutex_init($m3addr, robust: false); -$flags[2] = 0; -$pid = pcntl_fork(); -if ($pid === 0) { - libc()->pthread_mutex_lock(spike_mutex_at($m3addr)); - $flags[2] = 1; - posix_kill(posix_getpid(), SIGKILL); - spike_hard_exit(1); -} -while ($flags[2] === 0) { - usleep(1000); -} -spike_wait([$pid]); - -// trylock instead of lock: a non-robust orphaned mutex would block FOREVER -$rc = $ffi->pthread_mutex_trylock($m3); -printf(" pthread_mutex_trylock() on the orphaned non-robust mutex = %d (EBUSY == %d)\n", $rc, EBUSY); -spike_result('S8c a NON-robust pshared mutex is permanently stuck after an owner dies', $rc === EBUSY, - 'lock() here would block forever — PTHREAD_MUTEX_ROBUST is not optional for a multi-process arena'); - -// =========================================================================== -// S15 — bump allocation under the mutex -// =========================================================================== -echo "\n"; -const CHILDREN = 4; -const PER_CHILD = 25000; -const MAX_RECS = CHILDREN * PER_CHILD; - -/** - * @param bool $useMutex whether the bump pointer is carved under the lock - */ -$runBump = static function (bool $useMutex) use ($arena, $bump, $recN, $recs, $flags): array { - $bump[0] = OFF_HEAP; - $recN[0] = 0; - libc()->memset(spike_at('char', $arena + OFF_RECS), 0, MAX_RECS * 3 * 8); - - $pids = spike_fork(CHILDREN, function (int $role) use ($arena, $bump, $recN, $recs, $useMutex): int { - $ffi = libc(); - $mutex = spike_mutex_at($arena + OFF_MUTEX_B); - $tag = $role + 1; - - for ($i = 0; $i < PER_CHILD; $i++) { - $size = 16 + (($i * 48 + $role * 16) % 208); // 16..224, always 16-aligned - $size = ($size + 15) & ~15; - - if ($useMutex) { - $ffi->pthread_mutex_lock($mutex); - } - // Read-modify-write with a deliberately widened window, identical in both - // modes so the comparison is fair: the mutex is the ONLY difference. - $offset = $bump[0]; - $slot = $recN[0]; - $next = $offset + $size; - for ($w = 0; $w < 4; $w++) { - $next |= 0; - } - $bump[0] = $next; - $recN[0] = $slot + 1; - if ($useMutex) { - $ffi->pthread_mutex_unlock($mutex); - } - - $recs[$slot * 3 + 0] = $offset; - $recs[$slot * 3 + 1] = $size; - $recs[$slot * 3 + 2] = $tag; - - // Stamp the block with this child's tag: an overlap shows up as a - // block containing somebody else's byte. - $ffi->memset(spike_at('char', $arena + $offset), $tag, $size); - } - - return 0; - }); - - return [spike_wait($pids), (int) $bump[0], (int) $recN[0]]; -}; - -$verify = static function (int $records) use ($arena, $recs): array { - // 1. overlap check by sorting the intervals - $intervals = []; - for ($i = 0; $i < $records; $i++) { - $intervals[] = [$recs[$i * 3], $recs[$i * 3 + 1], $recs[$i * 3 + 2]]; - } - usort($intervals, static fn (array $a, array $b): int => $a[0] <=> $b[0]); - - $overlaps = 0; - $prevEnd = 0; - $duplicateOffsets = 0; - $prevStart = -1; - foreach ($intervals as [$off, $size, $tag]) { - if ($off === $prevStart) { - $duplicateOffsets++; - } - if ($off < $prevEnd) { - $overlaps++; - } - $prevEnd = max($prevEnd, $off + $size); - $prevStart = $off; - } - - // 2. content check: every byte of a block must carry its own tag - $corrupt = 0; - foreach ($intervals as [$off, $size, $tag]) { - $bytes = FFI::string(spike_at('char', $arena + $off), $size); - if ($bytes !== str_repeat(chr($tag), $size)) { - $corrupt++; - } - } - - return [$overlaps, $duplicateOffsets, $corrupt]; -}; - -spike_step(sprintf('S15a — %d children, %d bump allocations each, UNDER the mutex', CHILDREN, PER_CHILD)); -$t0 = microtime(true); -[$waits, $endBump, $records] = $runBump(true); -$dt = microtime(true) - $t0; -printf(" children: %s (%.2f s)\n", spike_describe_wait($waits), $dt); -[$ov, $dup, $bad] = $verify($records); -printf(" %d records, %d bytes carved (bump %d -> %d), %d overlaps, %d duplicate offsets, %d corrupted blocks\n", - $records, $endBump - OFF_HEAP, OFF_HEAP, $endBump, $ov, $dup, $bad); -spike_result('S15a locked bump allocation: no overlaps, no duplicate offsets, no corruption', - $records === MAX_RECS && $ov === 0 && $dup === 0 && $bad === 0); - -spike_step('S15b — CONTROL: the identical run with NO mutex (up to 3 attempts; a race is probabilistic)'); -$raced = false; -for ($attempt = 1; $attempt <= 3 && !$raced; $attempt++) { - $t0 = microtime(true); - [$waits, $endBump, $records] = $runBump(false); - $dt = microtime(true) - $t0; - [$ov, $dup, $bad] = $verify($records); - $raced = $records !== MAX_RECS || $ov > 0 || $dup > 0 || $bad > 0; - printf(" attempt %d (%.2f s): %d records (expected %d), %d bytes carved, %d overlaps, %d duplicate offsets, %d corrupted blocks\n", - $attempt, $dt, $records, MAX_RECS, $endBump - OFF_HEAP, $ov, $dup, $bad); -} -spike_result('S15b unlocked bump allocation races (lost updates and overlapping blocks)', $raced, - $raced - ? 'the mutex in S15a is load-bearing, not decoration' - : 'no race surfaced in 3 attempts on this machine — the hazard is still real, it is just timing-dependent'); - -echo "\nDone.\n"; diff --git a/spikes/c1/S12_cross_process_mutation.php b/spikes/c1/S12_cross_process_mutation.php deleted file mode 100644 index 87d9b6b..0000000 --- a/spikes/c1/S12_cross_process_mutation.php +++ /dev/null @@ -1,452 +0,0 @@ -prop = ...` write IS visible to the parent and to its siblings. - * - * Run: php -d ffi.enable=1 -d opcache.jit=off S12_cross_process_mutation.php - */ - -require __DIR__ . '/lib/bootstrap.php'; - -use ZEngine\Core; -use ZEngine\Reflection\ReflectionClass as ZReflectionClass; -use ZEngine\Reflection\ReflectionValue; - -spike_header('S12', 'cross-process mutation visibility'); - -// --------------------------------------------------------------------------- -// Arena layout (byte offsets inside one MAP_SHARED region) -// --------------------------------------------------------------------------- -const ARENA_SIZE = 4 << 20; // 4 MiB - -const OFF_MUTEX = 0; // 64 bytes (glibc pthread_mutex_t is 40, padded) -const OFF_ZVAL = 64; // 16 bytes: [value:8][u1.type_info:4][u2:4] -const OFF_MIRROR = 80; // 8 bytes: writer's copy of value, for torn detection -const OFF_CNT = 128; // counters, 8 bytes each -const CNT_WRITES = 0; -const CNT_READS = 1; -const CNT_TORN_LOCK = 2; -const CNT_TORN_FREE = 3; -const CNT_TYPEMIX = 4; -const CNT_LAST_SEEN = 5; -const CNT_FREE_READS = 6; -const OFF_OBJECTS = 4096; // bump area for engine-formatted objects - -$arena = spike_mmap_shared(ARENA_SIZE); -printf("arena: 0x%x .. 0x%x (%d bytes, MAP_SHARED|MAP_ANONYMOUS)\n\n", $arena, $arena + ARENA_SIZE, ARENA_SIZE); - -$cnt = spike_at('uint64_t', $arena + OFF_CNT); -$mutex = spike_mutex_init($arena + OFF_MUTEX, robust: false); - -$ITER = (int) (getenv('SPIKE_ITER') ?: 1000000); - -// =========================================================================== -// A1/A2 — hand-built zval slot, writer child + reader child -// =========================================================================== -spike_step(sprintf('A — %d iterations, 1 writer child + 1 locked reader child + 1 UNLOCKED reader child', $ITER)); - -const MASK = 0x5a5a5a5a5a5a5a5a; -const IS_LONG = 4; -const IS_DOUBLE = 5; - -$t0 = microtime(true); - -$pids = spike_fork(3, function (int $role) use ($arena, $ITER, $cnt): int { - $ffi = libc(); - $mutex = spike_mutex_at($arena + OFF_MUTEX); - $lval = spike_at('int64_t', $arena + OFF_ZVAL); - $dval = spike_at('double', $arena + OFF_ZVAL); - $tinfo = spike_at('uint32_t', $arena + OFF_ZVAL + 8); - $mirror = spike_at('int64_t', $arena + OFF_MIRROR); - - if ($role === 0) { - // WRITER: alternates a long generation and a double generation, so both - // halves of the zval change every iteration. - for ($i = 1; $i <= $ITER; $i++) { - $ffi->pthread_mutex_lock($mutex); - if (($i & 1) === 1) { - $lval[0] = $i; - $tinfo[0] = IS_LONG; - $mirror[0] = $i ^ MASK; - } else { - $dval[0] = (float) $i; - $tinfo[0] = IS_DOUBLE; - $mirror[0] = $lval[0] ^ MASK; // mirror of the raw 8 bytes - } - $cnt[CNT_WRITES] = $i; - $ffi->pthread_mutex_unlock($mutex); - } - - return 0; - } - - if ($role === 1) { - // LOCKED READER: every observation must be internally consistent. - $torn = 0; - $reads = 0; - $last = 0; - while ($cnt[CNT_WRITES] < $ITER) { - $ffi->pthread_mutex_lock($mutex); - $v = $lval[0]; - $t = $tinfo[0]; - $m = $mirror[0]; - $gen = $cnt[CNT_WRITES]; - $ffi->pthread_mutex_unlock($mutex); - $reads++; - if ($gen > 0) { - if (($m ^ MASK) !== $v) { - $torn++; - } - if ($t !== IS_LONG && $t !== IS_DOUBLE) { - $torn++; - } - // the value the writer published must never go backwards - if ($gen < $last) { - $torn++; - } - $last = $gen; - } - } - $cnt[CNT_READS] = $reads; - $cnt[CNT_TORN_LOCK] = $torn; - $cnt[CNT_LAST_SEEN] = $last; - - return 0; - } - - // UNLOCKED READER: reads the same 16 bytes with no synchronization at all. - // - value/mirror mismatch => the two 8-byte words are from different generations - // - type_info says LONG but the 8 bytes are a plausible double (or vice versa) - // => the value half and the type half came from different generations - $tornFree = 0; - $typeMix = 0; - $reads = 0; - while ($cnt[CNT_WRITES] < $ITER) { - $v = $lval[0]; - $t = $tinfo[0]; - $m = $mirror[0]; - $reads++; - if ($m !== 0 && ($m ^ MASK) !== $v) { - $tornFree++; - } - // A LONG generation always stores a small positive integer; a DOUBLE generation - // stores an IEEE-754 bit pattern whose magnitude as an integer is astronomically - // large. Seeing "type says LONG" together with a double bit pattern (or the - // reverse) proves the halves are from different generations. - $looksDouble = $v < 0 || $v > 0x0010000000000000; - if ($t === IS_LONG && $looksDouble) { - $typeMix++; - } elseif ($t === IS_DOUBLE && !$looksDouble && $v !== 0) { - $typeMix++; - } - } - $cnt[CNT_TORN_FREE] = $tornFree; - $cnt[CNT_TYPEMIX] = $typeMix; - $cnt[CNT_FREE_READS] = $reads; - - return 0; -}); - -$waits = spike_wait($pids); -$dt = microtime(true) - $t0; - -printf("children: %s (%.2f s, %.0f writes/s)\n", spike_describe_wait($waits), $dt, $ITER / max($dt, 1e-9)); -spike_result( - sprintf('A1 locked reader: %d reads, %d inconsistent observations', $cnt[CNT_READS], $cnt[CNT_TORN_LOCK]), - $cnt[CNT_TORN_LOCK] === 0, -); -spike_note(sprintf('locked reader last observed generation %d of %d (progress proves visibility)', $cnt[CNT_LAST_SEEN], $ITER)); -spike_result( - sprintf('A2 unlocked reader: %d reads, %d value/mirror mismatches, %d value-vs-type mismatches', - $cnt[CNT_FREE_READS], $cnt[CNT_TORN_FREE], $cnt[CNT_TYPEMIX]), - true, - ($cnt[CNT_TORN_FREE] + $cnt[CNT_TYPEMIX]) > 0 - ? 'EXPECTED: a 16-byte zval is NOT atomic; readers need the lock' - : 'no mismatch observed in this run (timing-dependent; the hazard is still real)', -); -echo "\n"; - -// =========================================================================== -// Phase B — real engine objects -// =========================================================================== -if (!Core::isInitialized()) { - spike_result('B skipped', false, 'z-engine is unavailable on this PHP minor'); - exit(0); -} - -final class S12Holder -{ - public int $counter = 0; - - public float $ratio = 0.0; - - public bool $flag = false; -} - -// --- B1: the malloc/COW negative control ------------------------------------ -spike_step('B1 — NEGATIVE CONTROL: persistent (malloc) clone + fork == copy-on-write'); - -$source = new S12Holder(); -$source->counter = 100; - -$value = new ReflectionValue($source); -$rawSource = $value->getRawObject(); -$ce = $rawSource->ce; -$objectSize = ZReflectionClass::getObjectSize($ce); -$value->release(); - -// Mint the same persistent clone php-shared-data-extension mints today. -$mallocClone = \ZEngine\Type\PersistentObjectFactory::persistentClone($rawSource); -Core::$executor->objectStore->put($mallocClone); -$mallocAddr = Core::addressOf($mallocClone); -$mallocValue = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $mallocClone[0]); -$mallocValue->getNativeValue($mallocInstance); - -printf(" persistent clone at 0x%x, object size %d bytes (malloc/pemalloc heap)\n", $mallocAddr, $objectSize); -$mallocInstance->counter = 100; - -// A shared scoreboard so the children can report back without serialization. -$score = spike_at('int64_t', $arena + OFF_CNT + 8 * 16); - -$pids = spike_fork(1, function () use ($mallocInstance, $score): int { - $mallocInstance->counter = 424242; // write in the child - $score[0] = $mallocInstance->counter; // child's own view - return 0; -}); -spike_wait($pids); - -printf(" child wrote counter=424242 (child read back %d)\n", $score[0]); -spike_result( - sprintf('B1 parent still sees counter=%d', $mallocInstance->counter), - $mallocInstance->counter === 100, - 'CONFIRMED: malloc memory is COW across fork — mutations are NOT shared', -); -echo "\n"; - -// --- B2: the same object living in the MAP_SHARED arena --------------------- -spike_step('B2 — THE PREMISE: byte-copy the engine-formatted object into MAP_SHARED and re-anchor'); - -$arenaObjectAddr = $arena + OFF_OBJECTS; -libc()->memcpy( - spike_at('char', $arenaObjectAddr), - spike_at('char', $mallocAddr), - $objectSize, -); -$arenaObject = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $arenaObjectAddr); - -// Re-anchor: give the arena-resident zend_object a request handle and materialize a -// PHP instance whose zval points straight at the arena address. -$handle = Core::$executor->objectStore->put($arenaObject); -$arenaValue = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $arenaObject[0]); -$arenaValue->getNativeValue($shared); - -printf(" arena object at 0x%x, handle %d, spl_object_id=%d, class=%s\n", - $arenaObjectAddr, $handle, spl_object_id($shared), get_class($shared)); - -$shared->counter = 7; -$shared->ratio = 0.5; -$shared->flag = false; -spike_result('B2 pre-fork read-back through the arena instance', $shared->counter === 7 && $shared->ratio === 0.5); - -// Children: A writes under the mutex, B reads under the mutex, C reads with NO lock. -// -// The invariant the readers check is a per-generation triple: for generation $i the -// object must hold counter=$i, ratio=$i/4.0, flag=odd($i). Any other combination means -// the reader saw a half-applied multi-property update. -$report = spike_at('int64_t', $arena + OFF_CNT + 8 * 20); -$stamp = spike_at('uint64_t', $arena + OFF_CNT + 8 * 32); // hrtime(true) of the last publish -$ROUNDS = 200000; - -$t0 = microtime(true); -$pids = spike_fork(3, function (int $role) use ($arena, $shared, $report, $stamp, $ROUNDS): int { - $ffi = libc(); - $mutex = spike_mutex_at($arena + OFF_MUTEX); - - if ($role === 0) { // writer - for ($i = 1; $i <= $ROUNDS; $i++) { - $ffi->pthread_mutex_lock($mutex); - $shared->counter = $i; - $shared->ratio = (float) $i / 4.0; - $shared->flag = ($i & 1) === 1; - $stamp[0] = hrtime(true); - $ffi->pthread_mutex_unlock($mutex); - } - $report[0] = 1; // writer done - - return 0; - } - - if ($role === 1) { // LOCKED reader - $reads = 0; - $bad = 0; - $last = 0; - $maxLagNs = 0; - while ($report[0] === 0) { - $ffi->pthread_mutex_lock($mutex); - $c = $shared->counter; - $r = $shared->ratio; - $f = $shared->flag; - $ts = $stamp[0]; - $ffi->pthread_mutex_unlock($mutex); - $reads++; - if ($c > 0) { - if ($r !== (float) $c / 4.0 || $f !== (($c & 1) === 1)) { - $bad++; - } - if ($c < $last) { - $bad++; - } - $last = $c; - $lag = hrtime(true) - $ts; - if ($lag > $maxLagNs) { - $maxLagNs = $lag; - } - } - } - $report[1] = $reads; - $report[2] = $bad; - $report[3] = $last; - $report[4] = $maxLagNs; - - return 0; - } - - // UNLOCKED reader: same triple, no mutex at all - $reads = 0; - $bad = 0; - while ($report[0] === 0) { - $c = $shared->counter; - $r = $shared->ratio; - $f = $shared->flag; - $reads++; - if ($c > 0 && ($r !== (float) $c / 4.0 || $f !== (($c & 1) === 1))) { - $bad++; - } - } - $report[5] = $reads; - $report[6] = $bad; - - return 0; -}); -$waits = spike_wait($pids); -$dt = microtime(true) - $t0; - -printf(" children: %s (%.2f s)\n", spike_describe_wait($waits), $dt); -printf(" LOCKED reader: %d reads, %d inconsistent, highest counter observed %d of %d, max value age %.1f us\n", - $report[1], $report[2], $report[3], $ROUNDS, $report[4] / 1000); -printf(" UNLOCKED reader: %d reads, %d inconsistent (%.2f%%)\n", - $report[5], $report[6], $report[5] > 0 ? 100 * $report[6] / $report[5] : 0.0); - -spike_result('B2 cross-process visibility of engine property writes', $report[3] > 1 && $report[2] === 0, - sprintf('parent now reads counter=%d ratio=%s flag=%s (written only by a child)', - $shared->counter, var_export($shared->ratio, true), var_export($shared->flag, true))); -spike_result('B2 unlocked multi-property reads are inconsistent', $report[6] > 0, - 'EXPECTED: multi-slot updates are not atomic — a reader must hold the same lock'); - -spike_result('B2 parent observes the LAST child write', $shared->counter === $ROUNDS, - sprintf('expected %d', $ROUNDS)); - -// Object identity survives: the parent's own zval still points at the same arena bytes -spike_result('B2 arena object identity stable in parent', Core::addressOf($arenaObject) === $arenaObjectAddr); - -// =========================================================================== -// C — the reverse direction: a child allocates a NEW object in the arena and -// hands its 8-byte address to the parent over a pipe (E1 acceptance #2) -// =========================================================================== -echo "\n"; -spike_step('C — child bump-allocates a NEW shared object POST-fork; the parent attaches it by address'); - -$bump = spike_at('uint64_t', $arena + OFF_CNT + 8 * 40); -$bump[0] = OFF_OBJECTS + 65536; // bump cursor, past the B2 object - -[$parentEnd, $childEnd] = spike_pipe(); - -$pid = pcntl_fork(); -if ($pid === 0) { - fclose($parentEnd); - $ffi = libc(); - $mutex = spike_mutex_at($arena + OFF_MUTEX); - - $fresh = new S12Holder(); - $fresh->counter = 31337; - $fresh->ratio = 2.5; - $fresh->flag = true; - - $fv = new ReflectionValue($fresh); - $rawF = $fv->getRawObject(); - $sz = ZReflectionClass::getObjectSize($rawF->ce); - - // bump-allocate under the arena lock, 16-byte aligned - $ffi->pthread_mutex_lock($mutex); - $off = ($bump[0] + 15) & ~15; - $bump[0] = $off + $sz; - $ffi->pthread_mutex_unlock($mutex); - - $addr = $arena + $off; - $ffi->memcpy(spike_at('char', $addr), spike_at('char', Core::addressOf($rawF)), $sz); - $fv->release(); - - // Same GC surgery PersistentObjectFactory::persistentClone() performs, applied to - // an ARENA block instead of a malloc block. - $arenaObj = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $addr); - $arenaObj->gc->refcount = \ZEngine\Type\PersistentObjectFactory::PIN_BASELINE; - $arenaObj->gc->u->type_info = Core::engineConstant('GC_OBJECT') - | Core::engineConstant('GC_NOT_COLLECTABLE') - | Core::engineConstant('GC_PERSISTENT'); - $arenaObj->extra_flags |= Core::engineConstant('IS_OBJ_DESTRUCTOR_CALLED') - | Core::engineConstant('IS_OBJ_FREE_CALLED'); - $arenaObj->handlers = Core::cast(\ZEngine\Generated\zend_object_handlers::class, - Core::addr(Core::getStandardObjectHandlers())); - $arenaObj->properties = null; - - fwrite($childEnd, pack('JJ', $addr, $sz)); - fflush($childEnd); - spike_hard_exit(0); -} -fclose($childEnd); -$msg = unpack('Jaddr/Jsize', (string) fread($parentEnd, 16)); -$wait = spike_wait([$pid]); -printf(" child: %s; it published a %d-byte object at 0x%x (8 bytes over the pipe, no serialization)\n", - spike_describe_wait($wait), $msg['size'], $msg['addr']); - -$inArena = $msg['addr'] > $arena && $msg['addr'] < $arena + ARENA_SIZE; -$newObj = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $msg['addr']); -Core::$executor->objectStore->put($newObj); -$newVal = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $newObj[0]); -$newVal->getNativeValue($adopted); - -printf(" parent attached it: class=%s counter=%d ratio=%s flag=%s\n", - get_class($adopted), $adopted->counter, var_export($adopted->ratio, true), var_export($adopted->flag, true)); -spike_result('C an object created by a child post-fork is readable by the parent', $inArena - && $adopted instanceof S12Holder - && $adopted->counter === 31337 && $adopted->ratio === 2.5 && $adopted->flag === true); -spike_note('the child had already exited: only the arena bytes survive, and that is enough'); - -echo "\nDone.\n"; - -// Deliberately leave the arena mapped; the process is about to exit anyway. diff --git a/spikes/c1/S13_shared_ardata.php b/spikes/c1/S13_shared_ardata.php deleted file mode 100644 index 0356fa8..0000000 --- a/spikes/c1/S13_shared_ardata.php +++ /dev/null @@ -1,359 +0,0 @@ -/linux-x64-nts/engine.h: - * - * struct _zend_array { // 56 bytes - * zend_refcounted_h gc; // +0 - * union { ... } u; // +8 (flags) - * uint32_t nTableMask; // +12 - * union { uint32_t *arHash; Bucket *arData; zval *arPacked; }; // +16 - * uint32_t nNumUsed; // +24 - * uint32_t nNumOfElements; // +28 - * uint32_t nTableSize; // +32 - * uint32_t nInternalPointer; // +36 - * zend_long nNextFreeElement; // +40 - * dtor_func_t pDestructor; // +48 - * }; - * typedef struct _Bucket { zval val; zend_ulong h; zend_string *key; } Bucket; // 32 bytes - * - * The data block the engine allocates is ONE allocation: - * [ hash slots: HT_HASH_SIZE(nTableMask) bytes ][ Bucket arData[nTableSize] ] - * with HT_HASH_SIZE(mask) == (uint32_t)(-(int32_t)mask) * sizeof(uint32_t) and - * HT_GET_DATA_ADDR(ht) == (char*)ht->arData - HT_HASH_SIZE(ht->nTableMask). - * Relocating a table therefore means moving that one block and re-pointing arData. - * - * Steps: - * A build + seal a table with N entries, relocate struct AND data block into the arena - * B pre-fork sanity: count/foreach/lookup through a real PHP array zval - * C post-fork: three children read it concurrently (foreach, count, lookup) - * D in-place bucket VALUE overwrite by one child, observed by another, under a mutex - * E THE TRAP: make the table grow. arData is replaced by a pointer into the growing - * process's PRIVATE heap, and because the STRUCT is shared, every other process - * immediately follows that dangling pointer. - * - * Run: php -d ffi.enable=1 -d opcache.jit=off S13_shared_ardata.php - */ - -require __DIR__ . '/lib/bootstrap.php'; - -use ZEngine\Core; -use ZEngine\Reflection\ReflectionValue; -use ZEngine\Type\HashTable; -use ZEngine\Type\PersistentHashTable; -use ZEngine\Type\StringEntry; - -spike_header('S13', 'pre-sized arData in shared memory'); - -if (!Core::isInitialized()) { - spike_result('S13 skipped', false, 'z-engine is unavailable on this PHP minor'); - exit(0); -} - -const ARENA_SIZE = 4 << 20; -const OFF_MUTEX = 0; -const OFF_REPORT = 256; // int64 scoreboard -const OFF_HT = 1024; // zend_array struct -const OFF_HTDATA = 4096; // relocated data block -const SIZEOF_BUCKET = 32; - -$arena = spike_mmap_shared(ARENA_SIZE); -$mutex = spike_mutex_init($arena + OFF_MUTEX); -$report = spike_at('int64_t', $arena + OFF_REPORT); - -printf("arena 0x%x, sizeof(zend_array)=%d, sizeof(Bucket)=%d, sizeof(zval)=%d\n\n", - $arena, - FFI::sizeof(Core::new('HashTable')), - FFI::sizeof(Core::new('Bucket')), - FFI::sizeof(Core::new('zval'))); - -// =========================================================================== -// A — build, seal, relocate -// =========================================================================== -const N = 64; - -spike_step(sprintf('A — build a %d-entry persistent table and relocate it into the arena', N)); - -$table = new PersistentHashTable(); -for ($i = 0; $i < N; $i++) { - $v = ReflectionValue::newEntry(ReflectionValue::IS_LONG, Core::new('zval'), true); - $v->setNativeValue($i * 10); - $table->add('k' . $i, $v); - $v->release(); -} -$table->markImmutable(); - -$raw = (new ReflectionProperty(HashTable::class, 'pointer'))->getValue($table); - -/** Signed reading of the uint32 nTableMask field. */ -$signedMask = static function (int $mask32): int { - return $mask32 >= 0x80000000 ? $mask32 - 0x100000000 : $mask32; -}; - -$flags = $raw->u->flags; -$isPacked = ($flags & 4) !== 0; // HASH_FLAG_PACKED -$mask = $signedMask($raw->nTableMask); -$hashSize = (-$mask) * 4; -$tableSize = $raw->nTableSize; -$dataSize = $tableSize * SIZEOF_BUCKET; -$arDataAddr = Core::addressOf($raw->arData); -$blockAddr = $arDataAddr - $hashSize; -$blockSize = $hashSize + $dataSize; -$structSize = FFI::sizeof(Core::new('HashTable')); - -printf(" source table: flags=0x%02x packed=%s nTableSize=%d nNumUsed=%d nNumOfElements=%d\n", - $flags, var_export($isPacked, true), $tableSize, $raw->nNumUsed, $raw->nNumOfElements); -printf(" nTableMask=%d HT_HASH_SIZE=%d HT_DATA_SIZE=%d one block of %d bytes at 0x%x\n", - $mask, $hashSize, $dataSize, $blockSize, $blockAddr); - -if ($isPacked) { - spike_result('A relocation', false, 'packed table: this spike deliberately targets the hash layout'); - exit(1); -} - -// Move the ONE data block, then the struct, then re-point arData. -libc()->memcpy(spike_at('char', $arena + OFF_HTDATA), spike_at('char', $blockAddr), $blockSize); -libc()->memcpy(spike_at('char', $arena + OFF_HT), spike_at('char', Core::addressOf($raw)), $structSize); - -$sharedHt = Core::pointerAtAddress(\ZEngine\Generated\HashTable::class, $arena + OFF_HT); -$sharedHt->arData = Core::pointerAtAddress(\ZEngine\Generated\Bucket::class, $arena + OFF_HTDATA + $hashSize); - -$sharedArDataAddr = Core::addressOf($sharedHt->arData); -printf(" arena table: struct at 0x%x, block at 0x%x, arData at 0x%x (inside arena: %s)\n", - $arena + OFF_HT, $arena + OFF_HTDATA, $sharedArDataAddr, - var_export($sharedArDataAddr > $arena && $sharedArDataAddr < $arena + ARENA_SIZE, true)); - -// NOTE: the string KEYS still point at persistent interned strings in malloc memory. -// They are read-only and COW-shared across fork, so lookups work — but they would NOT -// survive a fresh process. S16 covers moving strings into the arena. -spike_note('bucket KEYS still point at malloc-interned strings (COW-shared, fine across fork; see S16)'); - -// =========================================================================== -// B — pre-fork sanity through a real PHP array zval -// =========================================================================== -spike_step('B — materialize a PHP array zval pointing at the arena table'); - -$sharedValue = ReflectionValue::newEntry(ReflectionValue::IS_ARRAY, $sharedHt[0]); -printf(" zval type_info = 0x%x (GC_IMMUTABLE => non-refcounted IS_ARRAY = 0x7)\n", - (new ReflectionProperty(ReflectionValue::class, 'pointer'))->getValue($sharedValue)->u1->type_info); -$sharedValue->getNativeValue($sharedArray); - -spike_result('B count()', count($sharedArray) === N, 'got ' . count($sharedArray)); -spike_result('B lookup k7', ($sharedArray['k7'] ?? null) === 70, var_export($sharedArray['k7'] ?? null, true)); -spike_result('B array_sum over foreach', array_sum($sharedArray) === (int) (N * (N - 1) / 2 * 10), - 'sum=' . array_sum($sharedArray)); - -$wrapper = HashTable::fromCData($sharedHt); -spike_result('B z-engine HashTable view count', count($wrapper) === N, 'got ' . count($wrapper)); - -// =========================================================================== -// C + D — concurrent readers, in-place bucket value overwrite -// =========================================================================== -spike_step('C/D — 1 mutator child + 2 reader children, in-place scalar bucket overwrite under mutex'); - -// Address of the zval INSIDE the bucket for key 'k7' (bucket index == insertion order -// for a table that never had a delete). -$targetIndex = 7; -$targetZval = $sharedArDataAddr + $targetIndex * SIZEOF_BUCKET; // Bucket.val is at offset 0 -printf(" bucket[%d].val zval at 0x%x\n", $targetIndex, $targetZval); - -$ROUNDS = 200000; - -$t0 = microtime(true); -$pids = spike_fork(3, function (int $role) use ($arena, $sharedArray, $report, $targetZval, $ROUNDS): int { - $ffi = libc(); - $mutex = spike_mutex_at($arena + OFF_MUTEX); - $lval = spike_at('int64_t', $targetZval); - $tinfo = spike_at('uint32_t', $targetZval + 8); - - if ($role === 0) { // in-place scalar overwrite - for ($i = 1; $i <= $ROUNDS; $i++) { - $ffi->pthread_mutex_lock($mutex); - $lval[0] = $i; - $tinfo[0] = 4; // IS_LONG, stays scalar => no refcount work - $ffi->pthread_mutex_unlock($mutex); - } - $report[0] = 1; - - return 0; - } - - if ($role === 1) { // reader: value of the mutated key - $reads = 0; - $bad = 0; - $last = 0; - while ($report[0] === 0) { - $ffi->pthread_mutex_lock($mutex); - $v = $sharedArray['k7']; - $ffi->pthread_mutex_unlock($mutex); - $reads++; - if (!is_int($v) || $v < $last) { - $bad++; - } - $last = $v; - } - $report[1] = $reads; - $report[2] = $bad; - $report[3] = $last; - - return 0; - } - - // structural reader: full foreach + count while the other child mutates - $walks = 0; - $bad = 0; - while ($report[0] === 0) { - $n = 0; - $keys = 0; - foreach ($sharedArray as $k => $v) { - $n++; - if (is_string($k) && str_starts_with($k, 'k')) { - $keys++; - } - } - if ($n !== N || $keys !== N || count($sharedArray) !== N) { - $bad++; - } - $walks++; - } - $report[4] = $walks; - $report[5] = $bad; - - return 0; -}); -$waits = spike_wait($pids); -$dt = microtime(true) - $t0; - -printf(" children: %s (%.2f s)\n", spike_describe_wait($waits), $dt); -printf(" value reader: %d locked reads, %d anomalies, highest %d of %d\n", - $report[1], $report[2], $report[3], $ROUNDS); -printf(" structural reader: %d full foreach+count walks, %d anomalies\n", $report[4], $report[5]); - -spike_result('C concurrent foreach/count over a shared-memory table', - $report[4] > 0 && $report[5] === 0); -spike_result('D in-place scalar bucket overwrite is visible cross-process', - $report[3] > 1 && $report[2] === 0); -spike_result('D parent observes the last child write', $sharedArray['k7'] === $ROUNDS, - sprintf('parent reads k7=%s (expected %d)', var_export($sharedArray['k7'], true), $ROUNDS)); - -// =========================================================================== -// E — THE TRAP: growth relocates arData out of the arena -// =========================================================================== -echo "\n"; -spike_step('E — THE TRAP: what happens when the table has to grow'); - -// A separate arena so a heap-corrupting free() cannot scribble on the tables above. -$arena2 = spike_mmap_shared(1 << 20); -$report2 = spike_at('int64_t', $arena2 + 128); - -$small = new PersistentHashTable(); -for ($i = 0; $i < 6; $i++) { // HT_MIN_SIZE is 8 => 6 fits, 9 does not - $v = ReflectionValue::newEntry(ReflectionValue::IS_LONG, Core::new('zval'), true); - $v->setNativeValue($i); - $small->add('s' . $i, $v); - $v->release(); -} -$smallRaw = (new ReflectionProperty(HashTable::class, 'pointer'))->getValue($small); -$smallMask = $signedMask($smallRaw->nTableMask); -$smallHash = (-$smallMask) * 4; -$smallBlock = Core::addressOf($smallRaw->arData) - $smallHash; -$smallSize = $smallHash + $smallRaw->nTableSize * SIZEOF_BUCKET; - -libc()->memcpy(spike_at('char', $arena2 + 4096), spike_at('char', $smallBlock), $smallSize); -libc()->memcpy(spike_at('char', $arena2 + 1024), spike_at('char', Core::addressOf($smallRaw)), $structSize); -$sharedSmall = Core::pointerAtAddress(\ZEngine\Generated\HashTable::class, $arena2 + 1024); -$sharedSmall->arData = Core::pointerAtAddress(\ZEngine\Generated\Bucket::class, $arena2 + 4096 + $smallHash); - -$before = Core::addressOf($sharedSmall->arData); -printf(" arena2 0x%x .. 0x%x; small table nTableSize=%d nNumUsed=%d arData=0x%x (in arena: %s)\n", - $arena2, $arena2 + (1 << 20), $sharedSmall->nTableSize, $sharedSmall->nNumUsed, $before, - var_export($before > $arena2 && $before < $arena2 + (1 << 20), true)); - -// The growth happens in a SACRIFICIAL child: zend_hash_add() will pefree() the old data -// block, and that block is arena memory the process allocator never handed out. -$pids = spike_fork(1, function () use ($arena2, $sharedSmall, $report2, $before): int { - $wrapper = PersistentHashTable::fromCData($sharedSmall); - $report2[0] = 1; // "child reached the insert" - for ($i = 6; $i < 40; $i++) { // forces at least one zend_hash_do_resize - $v = ReflectionValue::newEntry(ReflectionValue::IS_LONG, Core::new('zval'), true); - $v->setNativeValue($i); - $wrapper->add('s' . $i, $v); - $v->release(); - $now = Core::addressOf($sharedSmall->arData); - if ($now !== $before) { - $report2[1] = 1; // arData moved - $report2[2] = $now; - $report2[3] = $sharedSmall->nTableSize; - $report2[4] = $i; - break; - } - } - - return 0; -}); -$waits = spike_wait($pids); -printf(" growth child: %s\n", spike_describe_wait($waits)); - -$after = Core::addressOf($sharedSmall->arData); -$inArena = $after > $arena2 && $after < $arena2 + (1 << 20); - -if ($report2[1] === 1) { - printf(" child saw arData move on insert #%d: 0x%x -> 0x%x (new nTableSize %d)\n", - $report2[4], $before, $report2[2], $report2[3]); -} else { - spike_note('the child never reported the move itself: it aborted inside the resize (see the signal above)'); -} -printf(" parent now reads ht->arData = 0x%x (inside arena2: %s)\n", $after, var_export($inArena, true)); - -spike_result('E growth is DETECTABLE (arData pointer changes in the shared struct)', - $after !== $before, - $after !== $before - ? 'the shared struct was rewritten by the child' - : 'no growth observed — the child may have died before resizing'); -spike_result('E grown arData points OUTSIDE the shared arena', !$inArena, - 'the parent would now dereference the dead child\'s private heap: DANGLING'); - -// What does a SIBLING see now? In another sacrificial child, walk the table whose struct -// says "40 elements" but whose arData points at a heap block this process never wrote. -$pids = spike_fork(1, function () use ($sharedSmall, $report2): int { - $wrapper = HashTable::fromCData($sharedSmall); - $n = 0; - $sum = 0; - foreach ($wrapper as $k => $v) { - $n++; - try { - $v->getNativeValue($native); - if (is_int($native)) { - $sum += $native; - } - } catch (\Throwable) { - $report2[7] = 1; - } - if ($n > 1000) { - break; - } - } - $report2[5] = $n; - $report2[6] = $sum; - - return 0; -}); -$waits = spike_wait($pids); -printf(" post-growth foreach child: %s\n", spike_describe_wait($waits)); -printf(" it walked %d entries summing to %d; the shared struct claims nNumOfElements=%d nTableSize=%d\n", - $report2[5], $report2[6], $sharedSmall->nNumOfElements, $sharedSmall->nTableSize); -spike_result('E a sibling reading the grown table gets SILENT garbage, not a crash', - true, - sprintf('walked %d of the %d elements the struct advertises — no fault, no signal, just wrong data', - $report2[5], $sharedSmall->nNumOfElements)); - -echo "\nDone.\n"; diff --git a/spikes/c1/S14_attach_side_effects.php b/spikes/c1/S14_attach_side_effects.php deleted file mode 100644 index 51bf1b4..0000000 --- a/spikes/c1/S14_attach_side_effects.php +++ /dev/null @@ -1,295 +0,0 @@ -handle. - * Every process needs its OWN handle (its object store is request/process memory), - * but they all write the same shared field. Last writer wins; every other process - * is left with an obj->handle that names a slot in ITS store belonging to a - * different object — and spl_object_id(), object comparison, the shutdown pass and - * ObjectStore::recycle() all read that field. - * - * B obj->properties is a LAZY, request-heap HashTable* that the engine materializes - * the first time anything asks for the property bag by name (get_object_vars(), - * var_dump(), json_encode(), (array) cast, ...). Written into a shared struct, the - * pointer is meaningless — and actively dangerous — in every other process. - * - * Run: php -d ffi.enable=1 -d opcache.jit=off S14_attach_side_effects.php - */ - -require __DIR__ . '/lib/bootstrap.php'; - -use ZEngine\Core; -use ZEngine\Reflection\ReflectionClass as ZReflectionClass; -use ZEngine\Reflection\ReflectionValue; -use ZEngine\Type\PersistentObjectFactory; - -spike_header('S14', 'per-process side effects of attach'); - -if (!Core::isInitialized()) { - spike_result('S14 skipped', false, 'z-engine is unavailable on this PHP minor'); - exit(0); -} - -const ARENA_SIZE = 1 << 20; -const OFF_MUTEX = 0; -const OFF_BAR = 128; // spin barrier -const OFF_REPORT = 256; -const OFF_OBJ = 4096; - -final class S14Holder -{ - public int $alpha = 1; - - public string $beta = 'b'; -} - -$arena = spike_mmap_shared(ARENA_SIZE); -$mutex = spike_mutex_init($arena + OFF_MUTEX); -$bar = spike_at('int64_t', $arena + OFF_BAR); -$report = spike_at('int64_t', $arena + OFF_REPORT); - -// --- place an engine-formatted object in the arena --------------------------- -$src = new S14Holder(); -$rv = new ReflectionValue($src); -$rawSrc = $rv->getRawObject(); -$size = ZReflectionClass::getObjectSize($rawSrc->ce); -$clone = PersistentObjectFactory::persistentClone($rawSrc); -$rv->release(); - -libc()->memcpy(spike_at('char', $arena + OFF_OBJ), spike_at('char', Core::addressOf($clone)), $size); -$shared = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $arena + OFF_OBJ); - -printf("shared zend_object at 0x%x, %d bytes; handle field currently %d, properties=0x%x\n\n", - $arena + OFF_OBJ, $size, $shared->handle, - $shared->properties === null ? 0 : Core::addressOf($shared->properties)); - -// =========================================================================== -// A — concurrent ObjectStore::put on the SAME shared struct -// =========================================================================== -spike_step('A — parent attaches, then two children attach the same object simultaneously'); - -$parentHandle = Core::$executor->objectStore->put($shared); -$parentValue = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); -$parentValue->getNativeValue($parentInstance); -printf(" parent put() -> handle %d, obj->handle=%d, spl_object_id=%d\n", - $parentHandle, $shared->handle, spl_object_id($parentInstance)); - -$pids = spike_fork(2, function (int $role) use ($shared, $bar, $report): int { - // Rendezvous so both put() calls really overlap. - $bar[0] = $bar[0] + 1; - while ($bar[0] < 2) { - // spin - } - - $handle = Core::$executor->objectStore->put($shared); - $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); - $value->getNativeValue($instance); - - $report[10 + $role * 4 + 0] = $handle; // handle the engine gave THIS process - $report[10 + $role * 4 + 1] = spl_object_id($instance); - usleep(20000); // let the sibling clobber the field - $report[10 + $role * 4 + 2] = $shared->handle; // what the shared field says afterwards - $report[10 + $role * 4 + 3] = spl_object_id($instance); // ...and what spl_object_id says now - - return 0; -}); -$waits = spike_wait($pids); -printf(" children: %s\n", spike_describe_wait($waits)); - -for ($r = 0; $r < 2; $r++) { - printf(" child %d: put() returned handle %d, spl_object_id right after = %d; ". - "20 ms later obj->handle=%d and spl_object_id=%d\n", - $r, $report[10 + $r * 4], $report[10 + $r * 4 + 1], $report[10 + $r * 4 + 2], $report[10 + $r * 4 + 3]); -} -printf(" parent afterwards: obj->handle=%d, spl_object_id(\$parentInstance)=%d (parent's real slot is %d)\n", - $shared->handle, spl_object_id($parentInstance), $parentHandle); - -spike_result('A obj->handle is a SHARED field every attaching process overwrites', - $shared->handle !== $parentHandle, - sprintf('parent attached at slot %d, shared field now says %d', $parentHandle, $shared->handle)); -spike_result('A spl_object_id() in the parent is now WRONG', - spl_object_id($parentInstance) !== $parentHandle, - 'spl_object_id() reads obj->handle directly — it returns a foreign process\'s slot number'); - -// What sits in the parent's own store at the clobbered handle? -$store = Core::$executor->objectStore; -$victim = $store[$shared->handle] ?? null; -printf(" parent's object store slot %d currently holds: %s\n", - $shared->handle, - $victim === null ? 'nothing / invalid bucket' : 'a DIFFERENT live object (' . get_class($victim->getNativeValue()) . ')'); -spike_note('recycle()/detach() at request end would therefore return a FOREIGN slot to the free list'); - -echo "\n"; - -// =========================================================================== -// B — the dynamic-properties pointer hazard -// =========================================================================== -spike_step('B — obj->properties: the lazy request-heap pointer written into a shared struct'); - -printf(" before: obj->properties = 0x%x\n", $shared->properties === null ? 0 : Core::addressOf($shared->properties)); - -// B1: child A merely asks for the property bag by name. -$pids = spike_fork(1, function () use ($shared, $report): int { - $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); - $value->getNativeValue($instance); - - $report[30] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); - $vars = get_object_vars($instance); // the trigger - $report[31] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); - $report[32] = count($vars); - - ob_start(); - var_dump($instance); // second common trigger - ob_end_clean(); - $report[33] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); - - $enc = json_encode($instance); - $report[34] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); - $report[35] = strlen((string) $enc); - - $cast = (array) $instance; - $report[36] = $shared->properties === null ? 0 : Core::addressOf($shared->properties); - $report[37] = count($cast); - - return 0; -}); -$waits = spike_wait($pids); -printf(" trigger child: %s\n", spike_describe_wait($waits)); -printf(" inside child A: properties 0x%x -> get_object_vars(%d vars) -> 0x%x -> var_dump -> 0x%x -> json_encode(%d bytes) -> 0x%x -> (array) cast(%d) -> 0x%x\n", - $report[30], $report[32], $report[31], $report[33], $report[35], $report[34], $report[37], $report[36]); - -$propsAfter = $shared->properties === null ? 0 : Core::addressOf($shared->properties); -printf(" PARENT now reads obj->properties = 0x%x (child A is gone; that is child A's private heap)\n", $propsAfter); - -$leaked = $propsAfter !== 0; -spike_result('B a read-only-looking call writes a request-heap pointer into the SHARED struct', - true, - $leaked - ? 'CONFIRMED: obj->properties is non-NULL in the shared struct after a child called get_object_vars()/var_dump()' - : 'NOT reproduced on this build: obj->properties stayed NULL (see note below)'); - -if ($leaked) { - // B2: a sibling that now touches the property bag follows the dangling pointer. - spike_step('B2 — sibling child B follows the inherited obj->properties pointer'); - $pids = spike_fork(1, function () use ($shared, $report): int { - $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); - $value->getNativeValue($instance); - - $report[40] = 1; // reached the child - $vars = get_object_vars($instance); - $report[41] = count($vars); - $report[42] = 1; - ob_start(); - var_dump($instance); - $dump = (string) ob_get_clean(); - $report[43] = strlen($dump); - $report[44] = 1; - - return 0; - }); - $waits = spike_wait($pids); - printf(" sibling child: %s\n", spike_describe_wait($waits)); - printf(" progress markers: reached=%d, get_object_vars returned %d vars (done=%d), var_dump produced %d bytes (done=%d)\n", - $report[40], $report[41], $report[42], $report[43], $report[44]); - - $crashed = $waits[array_key_first($waits)]['signal'] !== null; - spike_result('B2 sibling outcome', - true, - $crashed - ? 'CRASHED (signal ' . $waits[array_key_first($waits)]['signal'] . ') following the foreign properties pointer' - : sprintf('survived but read %d "properties" out of a heap block it never wrote — silent garbage', $report[41])); -} - -// B3: the sibling above only survived because fork() gave it the SAME copy-on-write heap -// layout as the writer, so the address happened to be mapped. A process that did not fork -// from the writer (a worker started later, a different pool member) has nothing there. -// Simulate that by pointing obj->properties at an address that is mapped in nobody. -spike_step('B3 — what a process that did NOT inherit the writer\'s heap sees'); - -$unmapped = $arena + (ARENA_SIZE * 64); // far past the arena: never mapped -$shared->properties = Core::pointerAtAddress(\ZEngine\Generated\HashTable::class, $unmapped); -printf(" obj->properties forced to 0x%x (unmapped in every process)\n", $unmapped); - -$pids = spike_fork(1, function () use ($shared, $report): int { - $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $shared[0]); - $value->getNativeValue($instance); - $report[50] = 1; - $vars = get_object_vars($instance); - $report[51] = count($vars); - - return 0; -}); -$waits = spike_wait($pids); -$w = $waits[array_key_first($waits)]; -$signal = $w['signal']; -$died = $signal !== null || $w['exit'] !== 0; -printf(" child: %s (reached=%d, returned %d vars)\n", spike_describe_wait($waits), $report[50], $report[51]); -spike_result('B3 dereferencing a foreign obj->properties kills the process', $died, - $signal !== null - ? 'SIGNAL ' . $signal . ' (' . spike_signame($signal) . ') — hard crash' - : ($died - ? 'engine bailed out with a fatal error (exit ' . $w['exit'] . ') after reading a garbage nTableSize' - : 'no fault observed on this run')); - -$shared->properties = null; // put the struct back into a sane state - -spike_note('the same field is also written by: property_exists on dynamic props, iteration over the object,'); -spike_note('serialize(), debug_zval_dump(), Reflection*::getProperties() and every (array)/json path.'); - -// =========================================================================== -// C — the third per-process field: obj->ce -// =========================================================================== -echo "\n"; -spike_step('C — obj->ce and obj->handlers: which of them is really fork-stable?'); - -$handlersAddr = Core::addressOf(Core::addr(Core::getStandardObjectHandlers())); -$ceAddr = Core::addressOf($shared->ce); -printf(" parent: std_object_handlers=0x%x, S14Holder ce=0x%x\n", $handlersAddr, $ceAddr); - -$pids = spike_fork(2, function (int $role) use ($shared, $report): int { - $report[60 + $role * 4 + 0] = Core::addressOf(Core::addr(Core::getStandardObjectHandlers())); - $report[60 + $role * 4 + 1] = Core::addressOf($shared->ce); - - // A class DEFINED AFTER the fork: its class entry comes out of this process's own - // compiler arena. Child 0 declares decoy classes first, which is all it takes for - // the two children to place "the same" class at different addresses — the realistic - // case being two workers that autoload different things in a different order. - if ($role === 0) { - for ($i = 0; $i < 40; $i++) { - eval("class S14Decoy{$i} { public int \$a = 1; public string \$b = 'x'; }"); - } - } - eval('class S14LateClass { public int $v = 1; }'); - $lateValue = Core::$executor->classTable->find('s14lateclass'); - $report[60 + $role * 4 + 2] = $lateValue === null ? 0 : Core::addressOf($lateValue->getRawClass()); - - return 0; -}); -spike_wait($pids); - -printf(" child 0: handlers=0x%x S14Holder ce=0x%x post-fork S14LateClass ce=0x%x\n", - $report[60], $report[61], $report[62]); -printf(" child 1: handlers=0x%x S14Holder ce=0x%x post-fork S14LateClass ce=0x%x\n", - $report[64], $report[65], $report[66]); - -spike_result('C std_object_handlers is address-identical in every forked process', - $report[60] === $handlersAddr && $report[64] === $handlersAddr, - 'safe to keep INSIDE the shared struct'); -spike_result('C a PRE-fork class entry is address-identical too', - $report[61] === $ceAddr && $report[65] === $ceAddr, - 'obj->ce happens to agree — but only because the class was loaded before the fork'); -spike_result('C a POST-fork class entry differs per process', - $report[62] !== $report[66] && $report[62] !== 0 && $report[66] !== 0, - sprintf('0x%x vs 0x%x — obj->ce cannot be a shared field once classes are autoloaded lazily', - $report[62], $report[66])); - -echo "\nDone.\n"; diff --git a/spikes/c1/S16_string_swap.php b/spikes/c1/S16_string_swap.php deleted file mode 100644 index 5e6eefd..0000000 --- a/spikes/c1/S16_string_swap.php +++ /dev/null @@ -1,279 +0,0 @@ -getRawValue(); - $bytes = 24 + $entry->getLength() + 1; // gc + h + len + val[len] + NUL - libc()->memcpy(spike_at('char', $arena + $offset), spike_at('char', Core::addressOf($raw)), $bytes); - - return [$arena + $offset, $bytes, $entry]; -}; - -[$addrA, $sizeA, $entryA] = $internIntoArena('alpha-alpha-alpha-alpha', OFF_STR_A); -[$addrB, $sizeB, $entryB] = $internIntoArena('BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO', OFF_STR_B); - -$viewA = StringEntry::fromCData(Core::pointerAtAddress(\ZEngine\Generated\zend_string::class, $addrA)); -$viewB = StringEntry::fromCData(Core::pointerAtAddress(\ZEngine\Generated\zend_string::class, $addrB)); - -printf(" A at 0x%x (%d bytes) len=%d interned=%s value=%s\n", - $addrA, $sizeA, $viewA->getLength(), var_export($viewA->isInterned(), true), $viewA->getStringValue()); -printf(" B at 0x%x (%d bytes) len=%d interned=%s value=%s\n", - $addrB, $sizeB, $viewB->getLength(), var_export($viewB->isInterned(), true), $viewB->getStringValue()); -spike_result('A both strings readable from the arena', - $viewA->getStringValue() === 'alpha-alpha-alpha-alpha' && $viewB->getStringValue() === 'BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO'); - -// =========================================================================== -// A2 — a shared object whose string property points at arena string A -// =========================================================================== -final class S16Holder -{ - public string $name = 'initial'; - - public int $seq = 0; -} - -$src = new S16Holder(); -$rv = new ReflectionValue($src); -$raw = $rv->getRawObject(); -$size = ZReflectionClass::getObjectSize($raw->ce); -$clone = PersistentObjectFactory::persistentClone($raw); -$rv->release(); - -libc()->memcpy(spike_at('char', $arena + OFF_OBJ), spike_at('char', Core::addressOf($clone)), $size); -$sharedObj = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $arena + OFF_OBJ); -Core::$executor->objectStore->put($sharedObj); -$objValue = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $sharedObj[0]); -$objValue->getNativeValue($shared); - -// properties_table[0] is $name (declaration order). Point it at arena string A, -// non-refcounted IS_STRING (== 6) because the payload is GC_IMMUTABLE. -$slotAddr = $arena + OFF_OBJ + FFI::sizeof(Core::new('zend_object')) - FFI::sizeof(Core::new('zval')); -$slotPtr = spike_at('uint64_t', $slotAddr); -$slotType = spike_at('uint32_t', $slotAddr + 8); -$slotPtr[0] = $addrA; -$slotType[0] = 6; - -printf(" \$name slot zval at 0x%x (value word 8-byte aligned: %s)\n", - $slotAddr, var_export($slotAddr % 8 === 0, true)); -spike_result('A2 property reads through the arena string', $shared->name === 'alpha-alpha-alpha-alpha', - var_export($shared->name, true)); - -// =========================================================================== -// B/C — swap the pointer under a mutex and without one -// =========================================================================== -echo "\n"; -$ROUNDS = 300000; -spike_step(sprintf('B/C — %d pointer swaps by child 0; child 1 reads LOCKED, child 2 reads UNLOCKED', $ROUNDS)); - -$t0 = microtime(true); -$pids = spike_fork(3, function (int $role) use ($arena, $shared, $report, $slotAddr, $addrA, $addrB, $ROUNDS): int { - $ffi = libc(); - $mutex = spike_mutex_at($arena + OFF_MUTEX); - $slot = spike_at('uint64_t', $slotAddr); - $stamp = spike_at('uint64_t', $arena + OFF_REPORT + 8 * 60); - - if ($role === 0) { // swapper - for ($i = 1; $i <= $ROUNDS; $i++) { - $ffi->pthread_mutex_lock($mutex); - $slot[0] = ($i & 1) === 1 ? $addrB : $addrA; - $stamp[0] = hrtime(true); - $ffi->pthread_mutex_unlock($mutex); - } - $report[0] = 1; - - return 0; - } - - if ($role === 1) { // locked reader - $reads = 0; - $torn = 0; - $sawA = 0; - $sawB = 0; - $maxLag = 0; - while ($report[0] === 0) { - $ffi->pthread_mutex_lock($mutex); - $p = $slot[0]; - $name = $shared->name; // full PHP-level string read - $ts = $stamp[0]; - $ffi->pthread_mutex_unlock($mutex); - $reads++; - if ($p === $addrA) { - $sawA++; - if ($name !== 'alpha-alpha-alpha-alpha') { - $torn++; - } - } elseif ($p === $addrB) { - $sawB++; - if ($name !== 'BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO') { - $torn++; - } - } else { - $torn++; // a value that is neither: TORN - } - $lag = hrtime(true) - $ts; - if ($ts > 0 && $lag > $maxLag) { - $maxLag = $lag; - } - } - $report[1] = $reads; - $report[2] = $torn; - $report[3] = $sawA; - $report[4] = $sawB; - $report[5] = $maxLag; - - return 0; - } - - // unlocked reader: no mutex at all - $reads = 0; - $torn = 0; - $bad = 0; - $maxLag = 0; - while ($report[0] === 0) { - $p = $slot[0]; - $name = $shared->name; - $ts = $stamp[0]; - $reads++; - if ($p !== $addrA && $p !== $addrB) { - $torn++; // a torn 8-byte pointer read - } - if ($name !== 'alpha-alpha-alpha-alpha' && $name !== 'BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO') { - $bad++; // a string that is neither - } - $lag = hrtime(true) - $ts; - if ($ts > 0 && $lag > $maxLag) { - $maxLag = $lag; - } - } - $report[6] = $reads; - $report[7] = $torn; - $report[8] = $bad; - $report[9] = $maxLag; - - return 0; -}); -$waits = spike_wait($pids); -$dt = microtime(true) - $t0; - -printf(" children: %s (%.2f s)\n", spike_describe_wait($waits), $dt); -printf(" LOCKED reader: %d reads (A=%d B=%d), %d torn, max staleness %.1f us\n", - $report[1], $report[3], $report[4], $report[2], $report[5] / 1000); -printf(" UNLOCKED reader: %d reads, %d torn pointers, %d unexpected string values, max staleness %.1f us\n", - $report[6], $report[7], $report[8], $report[9] / 1000); - -spike_result('B locked pointer swap: never torn, both values observed', - $report[2] === 0 && $report[3] > 0 && $report[4] > 0); -spike_result('C UNLOCKED aligned 8-byte pointer swap: never torn either', - $report[7] === 0 && $report[8] === 0, - 'aligned 8-byte loads/stores are atomic on x86-64 — the lock buys ORDERING between slots, not per-pointer atomicity'); -printf(" parent reads \$shared->name = %s\n", var_export($shared->name, true)); - -// =========================================================================== -// D — control: the same swap on a MISALIGNED slot that straddles a cache line -// =========================================================================== -echo "\n"; -spike_step('D — control: the same 8-byte swap on a slot straddling a 4 KiB page boundary'); - -$straddleAddr = $arena + OFF_STRADD; -printf(" slot at 0x%x: offset %% 4096 = %d, offset %% 64 = %d — the 8 bytes span two pages\n", - $straddleAddr, $straddleAddr % 4096, $straddleAddr % 64); - -$D_ROUNDS = 3000000; -$pids = spike_fork(2, function (int $role) use ($arena, $report, $straddleAddr, $addrA, $addrB, $D_ROUNDS): int { - $slot = spike_at('uint64_t', $straddleAddr); - - if ($role === 0) { - for ($i = 1; $i <= $D_ROUNDS; $i++) { - $slot[0] = ($i & 1) === 1 ? $addrB : $addrA; - } - $report[20] = 1; - - return 0; - } - - $reads = 0; - $torn = 0; - while ($report[20] === 0) { - $p = $slot[0]; - $reads++; - if ($p !== 0 && $p !== $addrA && $p !== $addrB) { - $torn++; - } - } - $report[21] = $reads; - $report[22] = $torn; - - return 0; -}); -$waits = spike_wait($pids); -printf(" children: %s\n", spike_describe_wait($waits)); -printf(" misaligned unlocked reader: %d reads, %d TORN values\n", $report[21], $report[22]); -spike_result('D misaligned (page-straddling) unlocked reads', true, - $report[22] > 0 - ? sprintf('TORE %d times: the atomicity guarantee is alignment-dependent, arena zvals must stay 8-byte aligned', $report[22]) - : 'no tearing observed on this CPU, but the ISA gives no guarantee for a misaligned access — keep the alignment invariant'); - -echo "\nDone.\n"; diff --git a/spikes/c1/S17_closures_across_fork.php b/spikes/c1/S17_closures_across_fork.php deleted file mode 100644 index 5777678..0000000 --- a/spikes/c1/S17_closures_across_fork.php +++ /dev/null @@ -1,279 +0,0 @@ -getRawObject(); - $ptr = Core::cast(zend_closure::class, $raw); - $value->release(); - - return $ptr; -} - -// =========================================================================== -// (a) closures compiled BEFORE fork -// =========================================================================== -spike_step('(a) closures compiled PRE-fork, invoked concurrently by two children'); - -$base = 1000; -$factor = 7; - -$staticClosure = static function (int $x): int { // no $this at all - return $x * 3 + 1; -}; -$useClosure = function (int $x) use ($base, $factor): int { // captured scalars - return $base + $x * $factor; -}; - -final class S17Scope -{ - public int $offset = 5; - - public function make(): \Closure - { - return function (int $x): int { - return $x + $this->offset; - }; - } -} -$boundClosure = (new S17Scope())->make(); // bound $this - -$INVOKES = 100000; - -foreach (['static' => $staticClosure, 'use' => $useClosure, 'bound' => $boundClosure] as $label => $c) { - $p = closurePointer($c); - printf(" %-6s closure: zend_closure at 0x%x, handle %d, fn_flags=0x%x (HEAP_RT_CACHE=%s), op_array.opcodes=0x%x\n", - $label, - Core::addressOf($p), - $p->std->handle, - $p->func->common->fn_flags, - var_export(($p->func->common->fn_flags & Core::ZEND_ACC_HEAP_RT_CACHE) !== 0, true), - Core::addressOf($p->func->op_array->opcodes)); -} - -$t0 = microtime(true); -$pids = spike_fork(2, function (int $role) use ($staticClosure, $useClosure, $boundClosure, $report, $INVOKES): int { - $bad = 0; - $acc = 0; - for ($i = 0; $i < $INVOKES; $i++) { - $a = $staticClosure($i); - $b = $useClosure($i); - $c = $boundClosure($i); - if ($a !== $i * 3 + 1 || $b !== 1000 + $i * 7 || $c !== $i + 5) { - $bad++; - } - $acc += $a + $b + $c; - } - $report[$role * 4 + 0] = $bad; - $report[$role * 4 + 1] = $acc; - $report[$role * 4 + 2] = spl_object_id($staticClosure); - - return 0; -}); -$waits = spike_wait($pids); -$dt = microtime(true) - $t0; - -printf(" children: %s (%.2f s, %d invocations each of 3 closures)\n", - spike_describe_wait($waits), $dt, $INVOKES); -printf(" child 0: %d wrong results, checksum %d, closure spl_object_id %d\n", $report[0], $report[1], $report[2]); -printf(" child 1: %d wrong results, checksum %d, closure spl_object_id %d\n", $report[4], $report[5], $report[6]); - -spike_result('(a) pre-fork closures invoke correctly and identically in both children', - $report[0] === 0 && $report[4] === 0 && $report[1] === $report[5] && $report[1] > 0, - 'op_array, literals and the captured statics are all COW-shared read-only data'); -spike_note('run_time_cache is per-closure heap memory (ZEND_ACC_HEAP_RT_CACHE): each child COW-copies its own'); - -// =========================================================================== -// (b) a closure created AFTER fork, invoked in a sibling -// =========================================================================== -echo "\n"; -spike_step('(b) closure created POST-fork in child A, its address handed to child B over a pipe'); - -[$parentEnd, $childEnd] = spike_pipe(); - -$pidA = pcntl_fork(); -if ($pidA === 0) { - fclose($parentEnd); - // Push the heap forward so the new closure does NOT land on a page the parent - // already has: this is exactly the COW divergence a post-fork allocation causes. - $ballast = []; - for ($i = 0; $i < 20000; $i++) { - $ballast[] = str_repeat('x', 64) . $i; - } - $magic = 987654321; - $late = static function (int $x) use ($magic): int { - return $x + $magic; - }; - $ptr = closurePointer($late); - fwrite($childEnd, pack('J', Core::addressOf($ptr))); - fwrite($childEnd, pack('J', $late(1))); - fflush($childEnd); - usleep(200000); // stay alive briefly, then die WITH its heap - spike_hard_exit(0); -} -fclose($childEnd); -$payload = fread($parentEnd, 16); -$vals = unpack('Jaddr/Jresult', (string) $payload); -$lateAddr = $vals['addr']; -printf(" child A built the closure at 0x%x; in child A it returns %d for input 1\n", $lateAddr, $vals['result']); -pcntl_waitpid($pidA, $st); - -// Child B: a SIBLING of A that never saw A's allocation. -$pidB = pcntl_fork(); -if ($pidB === 0) { - $report[20] = 1; // reached - $raw = Core::pointerAtAddress(\ZEngine\Generated\zend_object::class, $lateAddr); - $value = ReflectionValue::newEntry(ReflectionValue::IS_OBJECT, $raw[0]); - $value->getNativeValue($alien); - $report[21] = 1; // materialized a PHP value at that address - $report[22] = is_object($alien) ? 1 : 0; - $report[23] = $alien instanceof \Closure ? 1 : 0; - if ($alien instanceof \Closure) { - $report[24] = 1; // about to invoke - $r = $alien(1); - $report[25] = 1; // survived the invoke - $report[26] = is_int($r) ? $r : -1; - } - spike_hard_exit(0); -} -$waits = spike_wait([$pidB]); -$w = $waits[$pidB]; -printf(" child B: %s\n", spike_describe_wait($waits)); -printf(" markers: reached=%d materialized=%d is_object=%d is_Closure=%d invoked=%d survived=%d result=%d\n", - $report[20], $report[21], $report[22], $report[23], $report[24], $report[25], $report[26]); - -$divergent = $w['signal'] !== null || $w['exit'] !== 0 || $report[23] !== 1 || $report[26] !== 987654322; -spike_result('(b) invoking a sibling-built closure by address is UNSAFE', $divergent, - $w['signal'] !== null - ? 'child B died with signal ' . $w['signal'] . ' (' . spike_signame($w['signal']) . ')' - : ($report[23] !== 1 - ? 'the address did not even hold a Closure in child B — COW divergence' - : ($report[26] !== 987654322 - ? 'child B invoked it and got ' . $report[26] . ' instead of 987654322' - : 'child B happened to agree — the heap had not diverged at that address (rerun)'))); -spike_note('every post-fork allocation lands on a private COW page; addresses are only meaningful'); -spike_note('inside the process that allocated them. Closures therefore cannot be shared by address.'); - -// =========================================================================== -// (c) pointer inventory of a zend_closure -// =========================================================================== -echo "\n"; -spike_step('(c) every pointer a zend_closure carries (feasibility of arena-cloning)'); - -$probe = function (int $x) use ($base): int { - static $calls = 0; - $calls++; - - return $x + $base + $calls; -}; -$probe(1); // materialize static_variables_ptr - -$p = closurePointer($probe); -$oa = $p->func->op_array; - -$fields = [ - 'zend_closure (whole struct)' => Core::addressOf($p), - 'std.ce (Closure class entry)' => $p->std->ce === null ? 0 : Core::addressOf($p->std->ce), - 'std.handlers' => $p->std->handlers === null ? 0 : Core::addressOf($p->std->handlers), - 'func.op_array.function_name' => $oa->function_name === null ? 0 : Core::addressOf($oa->function_name), - 'func.op_array.scope' => $oa->scope === null ? 0 : Core::addressOf($oa->scope), - 'func.op_array.arg_info' => $oa->arg_info === null ? 0 : Core::addressOf($oa->arg_info), - 'func.op_array.attributes' => $oa->attributes === null ? 0 : Core::addressOf($oa->attributes), - 'func.op_array.run_time_cache__ptr' => $oa->run_time_cache__ptr === null ? 0 : Core::addressOf($oa->run_time_cache__ptr), - 'func.op_array.opcodes' => $oa->opcodes === null ? 0 : Core::addressOf($oa->opcodes), - 'func.op_array.static_variables' => $oa->static_variables === null ? 0 : Core::addressOf($oa->static_variables), - 'func.op_array.static_variables_ptr__ptr' => $oa->static_variables_ptr__ptr === null ? 0 : Core::addressOf($oa->static_variables_ptr__ptr), - 'func.op_array.vars' => $oa->vars === null ? 0 : Core::addressOf($oa->vars), - 'func.op_array.refcount' => $oa->refcount === null ? 0 : Core::addressOf($oa->refcount), - 'func.op_array.literals' => $oa->literals === null ? 0 : Core::addressOf($oa->literals), - 'func.op_array.filename' => $oa->filename === null ? 0 : Core::addressOf($oa->filename), - 'func.op_array.dynamic_func_defs' => $oa->dynamic_func_defs === null ? 0 : Core::addressOf($oa->dynamic_func_defs), - 'func.op_array.live_range' => $oa->live_range === null ? 0 : Core::addressOf($oa->live_range), - 'func.op_array.try_catch_array' => $oa->try_catch_array === null ? 0 : Core::addressOf($oa->try_catch_array), - 'this_ptr.value' => $p->this_ptr->value->lval, - 'called_scope' => $p->called_scope === null ? 0 : Core::addressOf($p->called_scope), -]; - -printf(" %-42s %-18s %s\n", 'field', 'address', 'notes'); -foreach ($fields as $name => $addr) { - printf(" %-42s 0x%-16x %s\n", $name, $addr, $addr === 0 ? '(null)' : ''); -} -printf(" counts: num_args=%d last_var=%d T=%d last(opcodes)=%d last_literal=%d cache_size=%d num_dynamic_func_defs=%d\n", - $oa->num_args, $oa->last_var, $oa->T, $oa->last, $oa->last_literal, $oa->cache_size, $oa->num_dynamic_func_defs); -printf(" byte cost of a deep clone: opcodes %d*%d=%d, literals %d*%d=%d, vars %d*8=%d, arg_info %d*%d=%d\n", - $oa->last, FFI::sizeof(Core::new('zend_op')), $oa->last * FFI::sizeof(Core::new('zend_op')), - $oa->last_literal, FFI::sizeof(Core::new('zval')), $oa->last_literal * FFI::sizeof(Core::new('zval')), - $oa->last_var, $oa->last_var * 8, - $oa->num_args, FFI::sizeof(Core::new('zend_arg_info')), $oa->num_args * FFI::sizeof(Core::new('zend_arg_info'))); - -$nonNull = count(array_filter($fields, static fn (int $a): bool => $a !== 0)); -spike_result('(c) pointer inventory taken', true, - sprintf('%d of %d zend_closure/op_array pointer fields are non-NULL for a trivial closure', $nonNull, count($fields))); - -// Are the op_array pointers stable across fork? (They must be, for (a) to work.) -$pids = spike_fork(1, function () use ($probe, $report): int { - $q = closurePointer($probe); - $qoa = $q->func->op_array; - $report[30] = Core::addressOf($q); - $report[31] = $qoa->opcodes === null ? 0 : Core::addressOf($qoa->opcodes); - $report[32] = $qoa->literals === null ? 0 : Core::addressOf($qoa->literals); - $report[33] = $qoa->static_variables === null ? 0 : Core::addressOf($qoa->static_variables); - $report[34] = $q->std->handle; - - return 0; -}); -spike_wait($pids); -printf(" child sees the SAME closure at 0x%x (opcodes 0x%x, literals 0x%x, static_variables 0x%x, handle %d)\n", - $report[30], $report[31], $report[32], $report[33], $report[34]); -spike_result('(c) a pre-fork closure keeps identical addresses in the child', - $report[30] === Core::addressOf($p) && $report[31] === ($oa->opcodes === null ? 0 : Core::addressOf($oa->opcodes))); - -spike_note('run_time_cache__ptr and static_variables_ptr__ptr point into the REQUEST arena, not into'); -spike_note('the compiled op_array: an arena-resident closure would share those per-request slots'); -spike_note('between processes. Any closure design must re-mint them per process.'); - -echo "\nDone.\n"; diff --git a/spikes/c1/lib/bootstrap.php b/spikes/c1/lib/bootstrap.php deleted file mode 100644 index 9610899..0000000 --- a/spikes/c1/lib/bootstrap.php +++ /dev/null @@ -1,348 +0,0 @@ -= 80500 - ? [SPIKE_ROOT . '/zengine-85'] // master == 8.5.x-dev - : ['/home/user/z-engine']; // 8.4 branch == 8.4.x-dev - - foreach ($candidates as $dir) { - if (is_dir($dir . '/src') && is_dir($dir . '/include/' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION)) { - return $dir; - } - } - - return null; -} - -spl_autoload_register(static function (string $class): void { - static $prefixes = null; - if ($prefixes === null) { - $prefixes = []; - $ze = spike_zengine_dir(); - if ($ze !== null) { - $prefixes['ZEngine\\'] = $ze . '/src/'; - } - $prefixes['Lisachenko\\SharedData\\'] = '/home/user/php-shared-data-extension/src/'; - } - - foreach ($prefixes as $prefix => $baseDir) { - if (!str_starts_with($class, $prefix)) { - continue; - } - $file = $baseDir . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; - if (is_file($file)) { - require $file; - } - } -}); - -/** Boots z-engine's Core, returns null on success or the failure reason. */ -function spike_boot_zengine(): ?string -{ - $dir = spike_zengine_dir(); - if ($dir === null) { - return sprintf('no z-engine line available for PHP %s in this sandbox', PHP_VERSION); - } - try { - \ZEngine\Core::init(); - } catch (\Throwable $e) { - return get_class($e) . ': ' . $e->getMessage(); - } - - return null; -} - -// --------------------------------------------------------------------------- -// libc binding: mmap + robust pshared mutexes -// --------------------------------------------------------------------------- - -const PROT_READ = 1; -const PROT_WRITE = 2; -const MAP_SHARED = 0x01; -const MAP_PRIVATE = 0x02; -const MAP_ANONYMOUS = 0x20; // Linux x86-64 - -// glibc / Linux x86-64 constants -const PTHREAD_PROCESS_SHARED = 1; -const PTHREAD_MUTEX_ROBUST = 1; -const EOWNERDEAD = 130; -const ENOTRECOVERABLE = 131; -const EBUSY = 16; - -function libc(): FFI -{ - static $ffi = null; - if ($ffi !== null) { - return $ffi; - } - - // glibc x86-64: pthread_mutex_t is 40 bytes, pthread_mutexattr_t is 4. - // We over-size the opaque blobs to 64/8 bytes so a slot is cache-line sized. - $ffi = FFI::cdef(<<<'C' - typedef struct { char __opaque[64]; } spike_mutex_t; - typedef struct { char __opaque[8]; } spike_mutexattr_t; - - // NOTE: mmap is declared returning char* on purpose. FFI::cast('uintptr_t', $p) - // on a `void *` CData yields 0 in PHP 8.4/8.5 (void* is special-cased and the - // cast reinterprets the *pointee*); on any typed pointer it yields the address. - char *mmap(void *addr, size_t length, int prot, int flags, int fd, long offset); - int munmap(char *addr, size_t length); - int mprotect(char *addr, size_t len, int prot); - - int pthread_mutexattr_init(spike_mutexattr_t *attr); - int pthread_mutexattr_setpshared(spike_mutexattr_t *attr, int pshared); - int pthread_mutexattr_setrobust(spike_mutexattr_t *attr, int robust); - int pthread_mutexattr_settype(spike_mutexattr_t *attr, int type); - int pthread_mutex_init(spike_mutex_t *mutex, const spike_mutexattr_t *attr); - int pthread_mutex_lock(spike_mutex_t *mutex); - int pthread_mutex_trylock(spike_mutex_t *mutex); - int pthread_mutex_unlock(spike_mutex_t *mutex); - int pthread_mutex_consistent(spike_mutex_t *mutex); - int pthread_mutex_destroy(spike_mutex_t *mutex); - - char *memcpy(char *dest, const char *src, size_t n); - char *memset(char *s, int c, size_t n); - int memcmp(const char *a, const char *b, size_t n); - int getpid(void); - void _exit(int status); - unsigned int sleep(unsigned int seconds); - C, null); - - return $ffi; -} - -/** - * Anonymous shared mapping. Returns [void* cdata, size]. - */ -/** @return int base address of a fresh zero-filled MAP_SHARED|MAP_ANONYMOUS region */ -function spike_mmap_shared(int $size): int -{ - return spike_mmap($size, MAP_SHARED | MAP_ANONYMOUS); -} - -/** @return int base address of a fresh zero-filled MAP_PRIVATE|MAP_ANONYMOUS region */ -function spike_mmap_private(int $size): int -{ - return spike_mmap($size, MAP_PRIVATE | MAP_ANONYMOUS); -} - -function spike_mmap(int $size, int $flags): int -{ - $ptr = libc()->mmap(null, $size, PROT_READ | PROT_WRITE, $flags, -1, 0); - $addr = spike_addr($ptr); - if ($addr === 0 || $addr === -1) { - throw new RuntimeException(sprintf('mmap(size=%d, flags=0x%x) failed', $size, $flags)); - } - libc()->memset($ptr, 0, $size); - - return $addr; -} - -function spike_addr(object $p): int -{ - return (int) FFI::cast('uintptr_t', $p)->cdata; -} - -/** Materializes a typed pointer at a raw address (allocation-free view, via libc binding). */ -function spike_at(string $type, int $address): FFI\CData -{ - return libc()->cast($type . '*', $address); -} - -/** - * Initializes a process-shared (optionally robust) mutex at $address inside a shared mapping. - */ -function spike_mutex_init(int $address, bool $robust = false): FFI\CData -{ - $ffi = libc(); - $attr = $ffi->new('spike_mutexattr_t'); - $rc = $ffi->pthread_mutexattr_init(FFI::addr($attr)); - $rc |= $ffi->pthread_mutexattr_setpshared(FFI::addr($attr), PTHREAD_PROCESS_SHARED); - if ($robust) { - $rc |= $ffi->pthread_mutexattr_setrobust(FFI::addr($attr), PTHREAD_MUTEX_ROBUST); - } - if ($rc !== 0) { - throw new RuntimeException('pthread_mutexattr_* failed'); - } - - $mutex = $ffi->cast('spike_mutex_t*', $address); - $rc = $ffi->pthread_mutex_init($mutex, FFI::addr($attr)); - if ($rc !== 0) { - throw new RuntimeException("pthread_mutex_init failed: {$rc}"); - } - - return $mutex; -} - -function spike_mutex_at(int $address): FFI\CData -{ - return libc()->cast('spike_mutex_t*', $address); -} - -function spike_lock(FFI\CData $m): int -{ - return libc()->pthread_mutex_lock($m); -} - -function spike_unlock(FFI\CData $m): int -{ - return libc()->pthread_mutex_unlock($m); -} - -/** - * Typed engine pointer at a raw address, through z-engine's FFI binding - * (needed for zval / zend_object / zend_array / zend_string views). - */ -function spike_engine_at(string $type, int $address): object -{ - return \ZEngine\Core::pointerAtAddress($type, $address); -} - -// --------------------------------------------------------------------------- -// process helpers -// --------------------------------------------------------------------------- - -/** @return array{0:resource,1:resource} */ -function spike_pipe(): array -{ - $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); - if ($pair === false) { - throw new RuntimeException('stream_socket_pair failed'); - } - - return $pair; -} - -/** - * Forks $n children, runs $body($index) in each, exits the child with the returned code. - * - * @return list child pids - */ -function spike_fork(int $n, callable $body): array -{ - $pids = []; - for ($i = 0; $i < $n; $i++) { - $pid = pcntl_fork(); - if ($pid === -1) { - throw new RuntimeException('fork failed'); - } - if ($pid === 0) { - $code = 0; - try { - $code = (int) $body($i); - } catch (\Throwable $e) { - fwrite(STDERR, "child {$i} threw: " . get_class($e) . ': ' . $e->getMessage() . "\n"); - $code = 66; - } - // Hard exit: skip PHP shutdown so engine teardown never touches shared memory - spike_hard_exit($code); - } - $pids[] = $pid; - } - - return $pids; -} - -/** - * Leaves the child WITHOUT running PHP's shutdown sequence. - * - * Object/GC teardown in a forked child would walk (and free) memory that the parent and - * the siblings still own, so every spike child leaves through this door. - */ -function spike_hard_exit(int $code): never -{ - try { - libc()->_exit($code); - } catch (\Throwable) { - // fall through - } - exit($code); -} - -/** @return array pid => exit status description */ -function spike_wait(array $pids): array -{ - $result = []; - foreach ($pids as $pid) { - $status = 0; - pcntl_waitpid($pid, $status); - if (pcntl_wifexited($status)) { - $result[$pid] = ['exit' => pcntl_wexitstatus($status), 'signal' => null]; - } elseif (pcntl_wifsignaled($status)) { - $result[$pid] = ['exit' => null, 'signal' => pcntl_wtermsig($status)]; - } else { - $result[$pid] = ['exit' => null, 'signal' => null]; - } - } - - return $result; -} - -function spike_describe_wait(array $waits): string -{ - $parts = []; - foreach ($waits as $pid => $w) { - $parts[] = $w['signal'] !== null - ? sprintf('pid %d killed by signal %d (%s)', $pid, $w['signal'], spike_signame($w['signal'])) - : sprintf('pid %d exit %s', $pid, var_export($w['exit'], true)); - } - - return implode(', ', $parts); -} - -function spike_signame(int $sig): string -{ - $map = [4 => 'SIGILL', 6 => 'SIGABRT', 7 => 'SIGBUS', 8 => 'SIGFPE', 9 => 'SIGKILL', 11 => 'SIGSEGV']; - - return $map[$sig] ?? "sig{$sig}"; -} - -// --------------------------------------------------------------------------- -// reporting -// --------------------------------------------------------------------------- - -function spike_header(string $id, string $title): void -{ - printf("=== %s — %s ===\n", $id, $title); - printf("PHP %s (%s), ZTS=%s, pid=%d\n", PHP_VERSION, PHP_OS, ZEND_THREAD_SAFE ? 'yes' : 'no', getmypid()); - $err = spike_boot_zengine(); - printf("z-engine: %s\n\n", $err === null ? 'booted (' . spike_zengine_dir() . ')' : 'UNAVAILABLE — ' . $err); -} - -function spike_step(string $text): void -{ - printf("--- %s\n", $text); -} - -function spike_result(string $label, bool $ok, string $detail = ''): void -{ - printf("[%s] %s%s\n", $ok ? ' OK ' : 'FAIL', $label, $detail === '' ? '' : ' :: ' . $detail); -} - -function spike_note(string $text): void -{ - printf(" %s\n", $text); -} diff --git a/spikes/c1/out/S08_S15_mutex_and_bump-8.4.log b/spikes/c1/out/S08_S15_mutex_and_bump-8.4.log deleted file mode 100644 index 18f9fd0..0000000 --- a/spikes/c1/out/S08_S15_mutex_and_bump-8.4.log +++ /dev/null @@ -1,26 +0,0 @@ -=== S8/S15 — robust mutex recovery + bump-allocation race === -PHP 8.4.19 (Linux), ZTS=no, pid=28870 -z-engine: booted (/home/user/z-engine) - ---- S8a — child SIGKILLed while holding a ROBUST process-shared mutex - holder: pid 28871 killed by signal 9 (SIGKILL) - parent pthread_mutex_lock() returned 130 after 8.4 us (EOWNERDEAD == 130) -[ OK ] S8a lock on an orphaned robust mutex returns EOWNERDEAD (no deadlock) - consistent()=0 unlock()=0 then lock()=0 unlock()=0 -[ OK ] S8a pthread_mutex_consistent() restores the mutex ---- S8b — CONTROL: recover the EOWNERDEAD without calling consistent() - first lock() = 130, unlock without consistent(), next lock() = 131 (ENOTRECOVERABLE == 131) -[ OK ] S8b skipping consistent() poisons the mutex permanently :: the recovery handler is MANDATORY — a missed consistent() takes the whole arena down ---- S8c — CONTROL: a NON-robust pshared mutex whose owner dies - pthread_mutex_trylock() on the orphaned non-robust mutex = 16 (EBUSY == 16) -[ OK ] S8c a NON-robust pshared mutex is permanently stuck after an owner dies :: lock() here would block forever — PTHREAD_MUTEX_ROBUST is not optional for a multi-process arena - ---- S15a — 4 children, 25000 bump allocations each, UNDER the mutex - children: pid 28875 exit 0, pid 28876 exit 0, pid 28877 exit 0, pid 28879 exit 0 (0.19 s) - 100000 records, 11199712 bytes carved (bump 8388608 -> 19588320), 0 overlaps, 0 duplicate offsets, 0 corrupted blocks -[ OK ] S15a locked bump allocation: no overlaps, no duplicate offsets, no corruption ---- S15b — CONTROL: the identical run with NO mutex (up to 3 attempts; a race is probabilistic) - attempt 1 (0.05 s): 78088 records (expected 100000), 8671472 bytes carved, 2866 overlaps, 825 duplicate offsets, 4663 corrupted blocks -[ OK ] S15b unlocked bump allocation races (lost updates and overlapping blocks) :: the mutex in S15a is load-bearing, not decoration - -Done. diff --git a/spikes/c1/out/S08_S15_mutex_and_bump-8.5.log b/spikes/c1/out/S08_S15_mutex_and_bump-8.5.log deleted file mode 100644 index 7b967ec..0000000 --- a/spikes/c1/out/S08_S15_mutex_and_bump-8.5.log +++ /dev/null @@ -1,26 +0,0 @@ -=== S8/S15 — robust mutex recovery + bump-allocation race === -PHP 8.5.9 (Linux), ZTS=no, pid=28939 -z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) - ---- S8a — child SIGKILLed while holding a ROBUST process-shared mutex - holder: pid 28940 killed by signal 9 (SIGKILL) - parent pthread_mutex_lock() returned 130 after 8.2 us (EOWNERDEAD == 130) -[ OK ] S8a lock on an orphaned robust mutex returns EOWNERDEAD (no deadlock) - consistent()=0 unlock()=0 then lock()=0 unlock()=0 -[ OK ] S8a pthread_mutex_consistent() restores the mutex ---- S8b — CONTROL: recover the EOWNERDEAD without calling consistent() - first lock() = 130, unlock without consistent(), next lock() = 131 (ENOTRECOVERABLE == 131) -[ OK ] S8b skipping consistent() poisons the mutex permanently :: the recovery handler is MANDATORY — a missed consistent() takes the whole arena down ---- S8c — CONTROL: a NON-robust pshared mutex whose owner dies - pthread_mutex_trylock() on the orphaned non-robust mutex = 16 (EBUSY == 16) -[ OK ] S8c a NON-robust pshared mutex is permanently stuck after an owner dies :: lock() here would block forever — PTHREAD_MUTEX_ROBUST is not optional for a multi-process arena - ---- S15a — 4 children, 25000 bump allocations each, UNDER the mutex - children: pid 28943 exit 0, pid 28944 exit 0, pid 28945 exit 0, pid 28946 exit 0 (0.21 s) - 100000 records, 11199712 bytes carved (bump 8388608 -> 19588320), 0 overlaps, 0 duplicate offsets, 0 corrupted blocks -[ OK ] S15a locked bump allocation: no overlaps, no duplicate offsets, no corruption ---- S15b — CONTROL: the identical run with NO mutex (up to 3 attempts; a race is probabilistic) - attempt 1 (0.09 s): 80243 records (expected 100000), 9428128 bytes carved, 2546 overlaps, 737 duplicate offsets, 5341 corrupted blocks -[ OK ] S15b unlocked bump allocation races (lost updates and overlapping blocks) :: the mutex in S15a is load-bearing, not decoration - -Done. diff --git a/spikes/c1/out/S12_cross_process_mutation-8.4.log b/spikes/c1/out/S12_cross_process_mutation-8.4.log deleted file mode 100644 index b52e344..0000000 --- a/spikes/c1/out/S12_cross_process_mutation-8.4.log +++ /dev/null @@ -1,35 +0,0 @@ -=== S12 — cross-process mutation visibility === -PHP 8.4.19 (Linux), ZTS=no, pid=28820 -z-engine: booted (/home/user/z-engine) - -arena: 0x7fb8da600000 .. 0x7fb8daa00000 (4194304 bytes, MAP_SHARED|MAP_ANONYMOUS) - ---- A — 1000000 iterations, 1 writer child + 1 locked reader child + 1 UNLOCKED reader child -children: pid 28821 exit 0, pid 28822 exit 0, pid 28823 exit 0 (1.36 s, 737170 writes/s) -[ OK ] A1 locked reader: 1090181 reads, 0 inconsistent observations - locked reader last observed generation 1000000 of 1000000 (progress proves visibility) -[ OK ] A2 unlocked reader: 8615400 reads, 182045 value/mirror mismatches, 116039 value-vs-type mismatches :: EXPECTED: a 16-byte zval is NOT atomic; readers need the lock - ---- B1 — NEGATIVE CONTROL: persistent (malloc) clone + fork == copy-on-write - persistent clone at 0x55dad3612f70, object size 88 bytes (malloc/pemalloc heap) - child wrote counter=424242 (child read back 424242) -[ OK ] B1 parent still sees counter=100 :: CONFIRMED: malloc memory is COW across fork — mutations are NOT shared - ---- B2 — THE PREMISE: byte-copy the engine-formatted object into MAP_SHARED and re-anchor - arena object at 0x7fb8da601000, handle 37, spl_object_id=37, class=S12Holder -[ OK ] B2 pre-fork read-back through the arena instance - children: pid 28825 exit 0, pid 28826 exit 0, pid 28827 exit 0 (0.24 s) - LOCKED reader: 169107 reads, 0 inconsistent, highest counter observed 200000 of 200000, max value age 166.4 us - UNLOCKED reader: 2607122 reads, 89409 inconsistent (3.43%) -[ OK ] B2 cross-process visibility of engine property writes :: parent now reads counter=200000 ratio=50000.0 flag=false (written only by a child) -[ OK ] B2 unlocked multi-property reads are inconsistent :: EXPECTED: multi-slot updates are not atomic — a reader must hold the same lock -[ OK ] B2 parent observes the LAST child write :: expected 200000 -[ OK ] B2 arena object identity stable in parent - ---- C — child bump-allocates a NEW shared object POST-fork; the parent attaches it by address - child: pid 28828 exit 0; it published a 88-byte object at 0x7fb8da611000 (8 bytes over the pipe, no serialization) - parent attached it: class=S12Holder counter=31337 ratio=2.5 flag=true -[ OK ] C an object created by a child post-fork is readable by the parent - the child had already exited: only the arena bytes survive, and that is enough - -Done. diff --git a/spikes/c1/out/S12_cross_process_mutation-8.5.log b/spikes/c1/out/S12_cross_process_mutation-8.5.log deleted file mode 100644 index 5278fbf..0000000 --- a/spikes/c1/out/S12_cross_process_mutation-8.5.log +++ /dev/null @@ -1,35 +0,0 @@ -=== S12 — cross-process mutation visibility === -PHP 8.5.9 (Linux), ZTS=no, pid=28889 -z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) - -arena: 0x7f2c2b400000 .. 0x7f2c2b800000 (4194304 bytes, MAP_SHARED|MAP_ANONYMOUS) - ---- A — 1000000 iterations, 1 writer child + 1 locked reader child + 1 UNLOCKED reader child -children: pid 28890 exit 0, pid 28891 exit 0, pid 28892 exit 0 (1.25 s, 802988 writes/s) -[ OK ] A1 locked reader: 977876 reads, 0 inconsistent observations - locked reader last observed generation 1000000 of 1000000 (progress proves visibility) -[ OK ] A2 unlocked reader: 7466423 reads, 289045 value/mirror mismatches, 139907 value-vs-type mismatches :: EXPECTED: a 16-byte zval is NOT atomic; readers need the lock - ---- B1 — NEGATIVE CONTROL: persistent (malloc) clone + fork == copy-on-write - persistent clone at 0x562658b23680, object size 88 bytes (malloc/pemalloc heap) - child wrote counter=424242 (child read back 424242) -[ OK ] B1 parent still sees counter=100 :: CONFIRMED: malloc memory is COW across fork — mutations are NOT shared - ---- B2 — THE PREMISE: byte-copy the engine-formatted object into MAP_SHARED and re-anchor - arena object at 0x7f2c2b401000, handle 36, spl_object_id=36, class=S12Holder -[ OK ] B2 pre-fork read-back through the arena instance - children: pid 28894 exit 0, pid 28895 exit 0, pid 28896 exit 0 (0.23 s) - LOCKED reader: 178116 reads, 0 inconsistent, highest counter observed 200000 of 200000, max value age 106.9 us - UNLOCKED reader: 3064947 reads, 82000 inconsistent (2.68%) -[ OK ] B2 cross-process visibility of engine property writes :: parent now reads counter=200000 ratio=50000.0 flag=false (written only by a child) -[ OK ] B2 unlocked multi-property reads are inconsistent :: EXPECTED: multi-slot updates are not atomic — a reader must hold the same lock -[ OK ] B2 parent observes the LAST child write :: expected 200000 -[ OK ] B2 arena object identity stable in parent - ---- C — child bump-allocates a NEW shared object POST-fork; the parent attaches it by address - child: pid 28897 exit 0; it published a 88-byte object at 0x7f2c2b411000 (8 bytes over the pipe, no serialization) - parent attached it: class=S12Holder counter=31337 ratio=2.5 flag=true -[ OK ] C an object created by a child post-fork is readable by the parent - the child had already exited: only the arena bytes survive, and that is enough - -Done. diff --git a/spikes/c1/out/S13_shared_ardata-8.4.log b/spikes/c1/out/S13_shared_ardata-8.4.log deleted file mode 100644 index 9f265fa..0000000 --- a/spikes/c1/out/S13_shared_ardata-8.4.log +++ /dev/null @@ -1,39 +0,0 @@ -=== S13 — pre-sized arData in shared memory === -PHP 8.4.19 (Linux), ZTS=no, pid=28832 -z-engine: booted (/home/user/z-engine) - -arena 0x7f497cc00000, sizeof(zend_array)=56, sizeof(Bucket)=32, sizeof(zval)=16 - ---- A — build a 64-entry persistent table and relocate it into the arena - source table: flags=0x10 packed=false nTableSize=64 nNumUsed=64 nNumOfElements=64 - nTableMask=-128 HT_HASH_SIZE=512 HT_DATA_SIZE=2048 one block of 2560 bytes at 0x56171ebfbeb0 - arena table: struct at 0x7f497cc00400, block at 0x7f497cc01000, arData at 0x7f497cc01200 (inside arena: true) - bucket KEYS still point at malloc-interned strings (COW-shared, fine across fork; see S16) ---- B — materialize a PHP array zval pointing at the arena table - zval type_info = 0x7 (GC_IMMUTABLE => non-refcounted IS_ARRAY = 0x7) -[ OK ] B count() :: got 64 -[ OK ] B lookup k7 :: 70 -[ OK ] B array_sum over foreach :: sum=20160 -[ OK ] B z-engine HashTable view count :: got 64 ---- C/D — 1 mutator child + 2 reader children, in-place scalar bucket overwrite under mutex - bucket[7].val zval at 0x7f497cc012e0 - children: pid 28833 exit 0, pid 28834 exit 0, pid 28835 exit 0 (0.18 s) - value reader: 157828 locked reads, 0 anomalies, highest 200000 of 200000 - structural reader: 78054 full foreach+count walks, 0 anomalies -[ OK ] C concurrent foreach/count over a shared-memory table -[ OK ] D in-place scalar bucket overwrite is visible cross-process -[ OK ] D parent observes the last child write :: parent reads k7=200000 (expected 200000) - ---- E — THE TRAP: what happens when the table has to grow - arena2 0x7f497d4c3000 .. 0x7f497d5c3000; small table nTableSize=8 nNumUsed=6 arData=0x7f497d4c4040 (in arena: true) -free(): invalid pointer - growth child: pid 28836 killed by signal 6 (SIGABRT) - the child never reported the move itself: it aborted inside the resize (see the signal above) - parent now reads ht->arData = 0x56171ebfb310 (inside arena2: false) -[ OK ] E growth is DETECTABLE (arData pointer changes in the shared struct) :: the shared struct was rewritten by the child -[ OK ] E grown arData points OUTSIDE the shared arena :: the parent would now dereference the dead child's private heap: DANGLING - post-growth foreach child: pid 28837 exit 0 - it walked 8 entries summing to 280; the shared struct claims nNumOfElements=8 nTableSize=16 -[ OK ] E a sibling reading the grown table gets SILENT garbage, not a crash :: walked 8 of the 8 elements the struct advertises — no fault, no signal, just wrong data - -Done. diff --git a/spikes/c1/out/S13_shared_ardata-8.5.log b/spikes/c1/out/S13_shared_ardata-8.5.log deleted file mode 100644 index 1643a4f..0000000 --- a/spikes/c1/out/S13_shared_ardata-8.5.log +++ /dev/null @@ -1,39 +0,0 @@ -=== S13 — pre-sized arData in shared memory === -PHP 8.5.9 (Linux), ZTS=no, pid=28901 -z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) - -arena 0x7f548f200000, sizeof(zend_array)=56, sizeof(Bucket)=32, sizeof(zval)=16 - ---- A — build a 64-entry persistent table and relocate it into the arena - source table: flags=0x10 packed=false nTableSize=64 nNumUsed=64 nNumOfElements=64 - nTableMask=-128 HT_HASH_SIZE=512 HT_DATA_SIZE=2048 one block of 2560 bytes at 0x556660e16430 - arena table: struct at 0x7f548f200400, block at 0x7f548f201000, arData at 0x7f548f201200 (inside arena: true) - bucket KEYS still point at malloc-interned strings (COW-shared, fine across fork; see S16) ---- B — materialize a PHP array zval pointing at the arena table - zval type_info = 0x7 (GC_IMMUTABLE => non-refcounted IS_ARRAY = 0x7) -[ OK ] B count() :: got 64 -[ OK ] B lookup k7 :: 70 -[ OK ] B array_sum over foreach :: sum=20160 -[ OK ] B z-engine HashTable view count :: got 64 ---- C/D — 1 mutator child + 2 reader children, in-place scalar bucket overwrite under mutex - bucket[7].val zval at 0x7f548f2012e0 - children: pid 28902 exit 0, pid 28903 exit 0, pid 28904 exit 0 (0.19 s) - value reader: 183516 locked reads, 0 anomalies, highest 200000 of 200000 - structural reader: 112392 full foreach+count walks, 0 anomalies -[ OK ] C concurrent foreach/count over a shared-memory table -[ OK ] D in-place scalar bucket overwrite is visible cross-process -[ OK ] D parent observes the last child write :: parent reads k7=200000 (expected 200000) - ---- E — THE TRAP: what happens when the table has to grow - arena2 0x7f548f100000 .. 0x7f548f200000; small table nTableSize=8 nNumUsed=6 arData=0x7f548f101040 (in arena: true) -free(): invalid pointer - growth child: pid 28905 killed by signal 6 (SIGABRT) - the child never reported the move itself: it aborted inside the resize (see the signal above) - parent now reads ht->arData = 0x556660dbc410 (inside arena2: false) -[ OK ] E growth is DETECTABLE (arData pointer changes in the shared struct) :: the shared struct was rewritten by the child -[ OK ] E grown arData points OUTSIDE the shared arena :: the parent would now dereference the dead child's private heap: DANGLING - post-growth foreach child: pid 28906 exit 0 - it walked 8 entries summing to 280; the shared struct claims nNumOfElements=8 nTableSize=16 -[ OK ] E a sibling reading the grown table gets SILENT garbage, not a crash :: walked 8 of the 8 elements the struct advertises — no fault, no signal, just wrong data - -Done. diff --git a/spikes/c1/out/S14_attach_side_effects-8.4.log b/spikes/c1/out/S14_attach_side_effects-8.4.log deleted file mode 100644 index 7c0fe6e..0000000 --- a/spikes/c1/out/S14_attach_side_effects-8.4.log +++ /dev/null @@ -1,43 +0,0 @@ -=== S14 — per-process side effects of attach === -PHP 8.4.19 (Linux), ZTS=no, pid=28841 -z-engine: booted (/home/user/z-engine) - -shared zend_object at 0x7fc272eed000, 72 bytes; handle field currently 25, properties=0x0 - ---- A — parent attaches, then two children attach the same object simultaneously - parent put() -> handle 36, obj->handle=36, spl_object_id=36 - children: pid 28842 exit 0, pid 28843 exit 0 - child 0: put() returned handle 30, spl_object_id right after = 30; 20 ms later obj->handle=30 and spl_object_id=30 - child 1: put() returned handle 30, spl_object_id right after = 30; 20 ms later obj->handle=30 and spl_object_id=30 - parent afterwards: obj->handle=30, spl_object_id($parentInstance)=30 (parent's real slot is 36) -[ OK ] A obj->handle is a SHARED field every attaching process overwrites :: parent attached at slot 36, shared field now says 30 -[ OK ] A spl_object_id() in the parent is now WRONG :: spl_object_id() reads obj->handle directly — it returns a foreign process's slot number - parent's object store slot 30 currently holds: a DIFFERENT live object (FFI\CData) - recycle()/detach() at request end would therefore return a FOREIGN slot to the free list - ---- B — obj->properties: the lazy request-heap pointer written into a shared struct - before: obj->properties = 0x0 - trigger child: pid 28844 exit 0 - inside child A: properties 0x0 -> get_object_vars(2 vars) -> 0x7fc276d17968 -> var_dump -> 0x7fc276d17968 -> json_encode(22 bytes) -> 0x7fc276d17968 -> (array) cast(2) -> 0x7fc276d17968 - PARENT now reads obj->properties = 0x7fc276d17968 (child A is gone; that is child A's private heap) -[ OK ] B a read-only-looking call writes a request-heap pointer into the SHARED struct :: CONFIRMED: obj->properties is non-NULL in the shared struct after a child called get_object_vars()/var_dump() ---- B2 — sibling child B follows the inherited obj->properties pointer - sibling child: pid 28845 exit 0 - progress markers: reached=1, get_object_vars returned 1 vars (done=1), var_dump produced 50 bytes (done=1) -[ OK ] B2 sibling outcome :: survived but read 1 "properties" out of a heap block it never wrote — silent garbage ---- B3 — what a process that did NOT inherit the writer's heap sees - obj->properties forced to 0x7fc276eec000 (unmapped in every process) - child: pid 28846 killed by signal 11 (SIGSEGV) (reached=1, returned 0 vars) -[ OK ] B3 dereferencing a foreign obj->properties kills the process :: SIGNAL 11 (SIGSEGV) — hard crash - the same field is also written by: property_exists on dynamic props, iteration over the object, - serialize(), debug_zval_dump(), Reflection*::getProperties() and every (array)/json path. - ---- C — obj->ce and obj->handlers: which of them is really fork-stable? - parent: std_object_handlers=0x55780f8ae920, S14Holder ce=0x7fc276c04018 - child 0: handlers=0x55780f8ae920 S14Holder ce=0x7fc276c04018 post-fork S14LateClass ce=0x7fc272a587d0 - child 1: handlers=0x55780f8ae920 S14Holder ce=0x7fc276c04018 post-fork S14LateClass ce=0x7fc272a51d90 -[ OK ] C std_object_handlers is address-identical in every forked process :: safe to keep INSIDE the shared struct -[ OK ] C a PRE-fork class entry is address-identical too :: obj->ce happens to agree — but only because the class was loaded before the fork -[ OK ] C a POST-fork class entry differs per process :: 0x7fc272a587d0 vs 0x7fc272a51d90 — obj->ce cannot be a shared field once classes are autoloaded lazily - -Done. diff --git a/spikes/c1/out/S14_attach_side_effects-8.5.log b/spikes/c1/out/S14_attach_side_effects-8.5.log deleted file mode 100644 index 77cc811..0000000 --- a/spikes/c1/out/S14_attach_side_effects-8.5.log +++ /dev/null @@ -1,43 +0,0 @@ -=== S14 — per-process side effects of attach === -PHP 8.5.9 (Linux), ZTS=no, pid=28910 -z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) - -shared zend_object at 0x7f58a742a000, 72 bytes; handle field currently 25, properties=0x0 - ---- A — parent attaches, then two children attach the same object simultaneously - parent put() -> handle 35, obj->handle=35, spl_object_id=35 - children: pid 28911 exit 0, pid 28912 exit 0 - child 0: put() returned handle 36, spl_object_id right after = 36; 20 ms later obj->handle=36 and spl_object_id=36 - child 1: put() returned handle 36, spl_object_id right after = 36; 20 ms later obj->handle=36 and spl_object_id=36 - parent afterwards: obj->handle=36, spl_object_id($parentInstance)=36 (parent's real slot is 35) -[ OK ] A obj->handle is a SHARED field every attaching process overwrites :: parent attached at slot 35, shared field now says 36 -[ OK ] A spl_object_id() in the parent is now WRONG :: spl_object_id() reads obj->handle directly — it returns a foreign process's slot number - parent's object store slot 36 currently holds: a DIFFERENT live object (FFI\CData) - recycle()/detach() at request end would therefore return a FOREIGN slot to the free list - ---- B — obj->properties: the lazy request-heap pointer written into a shared struct - before: obj->properties = 0x0 - trigger child: pid 28913 exit 0 - inside child A: properties 0x0 -> get_object_vars(2 vars) -> 0x7f58a92ec818 -> var_dump -> 0x7f58a92ec818 -> json_encode(22 bytes) -> 0x7f58a92ec818 -> (array) cast(2) -> 0x7f58a92ec818 - PARENT now reads obj->properties = 0x7f58a92ec818 (child A is gone; that is child A's private heap) -[ OK ] B a read-only-looking call writes a request-heap pointer into the SHARED struct :: CONFIRMED: obj->properties is non-NULL in the shared struct after a child called get_object_vars()/var_dump() ---- B2 — sibling child B follows the inherited obj->properties pointer - sibling child: pid 28914 exit 0 - progress markers: reached=1, get_object_vars returned 1 vars (done=1), var_dump produced 50 bytes (done=1) -[ OK ] B2 sibling outcome :: survived but read 1 "properties" out of a heap block it never wrote — silent garbage ---- B3 — what a process that did NOT inherit the writer's heap sees - obj->properties forced to 0x7f58ab429000 (unmapped in every process) - child: pid 28915 killed by signal 11 (SIGSEGV) (reached=1, returned 0 vars) -[ OK ] B3 dereferencing a foreign obj->properties kills the process :: SIGNAL 11 (SIGSEGV) — hard crash - the same field is also written by: property_exists on dynamic props, iteration over the object, - serialize(), debug_zval_dump(), Reflection*::getProperties() and every (array)/json path. - ---- C — obj->ce and obj->handlers: which of them is really fork-stable? - parent: std_object_handlers=0x55ba43af0760, S14Holder ce=0x55ba378d8760 - child 0: handlers=0x55ba43af0760 S14Holder ce=0x55ba378d8760 post-fork S14LateClass ce=0x7f58a7235d58 - child 1: handlers=0x55ba43af0760 S14Holder ce=0x55ba378d8760 post-fork S14LateClass ce=0x7f58a722f318 -[ OK ] C std_object_handlers is address-identical in every forked process :: safe to keep INSIDE the shared struct -[ OK ] C a PRE-fork class entry is address-identical too :: obj->ce happens to agree — but only because the class was loaded before the fork -[ OK ] C a POST-fork class entry differs per process :: 0x7f58a7235d58 vs 0x7f58a722f318 — obj->ce cannot be a shared field once classes are autoloaded lazily - -Done. diff --git a/spikes/c1/out/S16_string_swap-8.4.log b/spikes/c1/out/S16_string_swap-8.4.log deleted file mode 100644 index 11c3dd3..0000000 --- a/spikes/c1/out/S16_string_swap-8.4.log +++ /dev/null @@ -1,26 +0,0 @@ -=== S16 — string swap visibility === -PHP 8.4.19 (Linux), ZTS=no, pid=28852 -z-engine: booted (/home/user/z-engine) - ---- A — arena-intern two zend_strings - A at 0x7faa30e00400 (48 bytes) len=23 interned=true value=alpha-alpha-alpha-alpha - B at 0x7faa30e00600 (60 bytes) len=35 interned=true value=BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO -[ OK ] A both strings readable from the arena - $name slot zval at 0x7faa30e01028 (value word 8-byte aligned: true) -[ OK ] A2 property reads through the arena string :: 'alpha-alpha-alpha-alpha' - ---- B/C — 300000 pointer swaps by child 0; child 1 reads LOCKED, child 2 reads UNLOCKED - children: pid 28853 exit 0, pid 28854 exit 0, pid 28855 exit 0 (0.32 s) - LOCKED reader: 267587 reads (A=135833 B=131754), 0 torn, max staleness 205.7 us - UNLOCKED reader: 2022502 reads, 0 torn pointers, 0 unexpected string values, max staleness 207.8 us -[ OK ] B locked pointer swap: never torn, both values observed -[ OK ] C UNLOCKED aligned 8-byte pointer swap: never torn either :: aligned 8-byte loads/stores are atomic on x86-64 — the lock buys ORDERING between slots, not per-pointer atomicity - parent reads $shared->name = 'alpha-alpha-alpha-alpha' - ---- D — control: the same 8-byte swap on a slot straddling a 4 KiB page boundary - slot at 0x7faa30e02ffc: offset % 4096 = 4092, offset % 64 = 60 — the 8 bytes span two pages - children: pid 28856 exit 0, pid 28857 exit 0 - misaligned unlocked reader: 1811937 reads, 0 TORN values -[ OK ] D misaligned (page-straddling) unlocked reads :: no tearing observed on this CPU, but the ISA gives no guarantee for a misaligned access — keep the alignment invariant - -Done. diff --git a/spikes/c1/out/S16_string_swap-8.5.log b/spikes/c1/out/S16_string_swap-8.5.log deleted file mode 100644 index d3ab045..0000000 --- a/spikes/c1/out/S16_string_swap-8.5.log +++ /dev/null @@ -1,26 +0,0 @@ -=== S16 — string swap visibility === -PHP 8.5.9 (Linux), ZTS=no, pid=28921 -z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) - ---- A — arena-intern two zend_strings - A at 0x7feba1400400 (48 bytes) len=23 interned=true value=alpha-alpha-alpha-alpha - B at 0x7feba1400600 (60 bytes) len=35 interned=true value=BRAVO-BRAVO-BRAVO-BRAVO-BRAVO-BRAVO -[ OK ] A both strings readable from the arena - $name slot zval at 0x7feba1401028 (value word 8-byte aligned: true) -[ OK ] A2 property reads through the arena string :: 'alpha-alpha-alpha-alpha' - ---- B/C — 300000 pointer swaps by child 0; child 1 reads LOCKED, child 2 reads UNLOCKED - children: pid 28922 exit 0, pid 28923 exit 0, pid 28924 exit 0 (0.32 s) - LOCKED reader: 251818 reads (A=126441 B=125377), 0 torn, max staleness 456.2 us - UNLOCKED reader: 2616769 reads, 0 torn pointers, 0 unexpected string values, max staleness 458.3 us -[ OK ] B locked pointer swap: never torn, both values observed -[ OK ] C UNLOCKED aligned 8-byte pointer swap: never torn either :: aligned 8-byte loads/stores are atomic on x86-64 — the lock buys ORDERING between slots, not per-pointer atomicity - parent reads $shared->name = 'alpha-alpha-alpha-alpha' - ---- D — control: the same 8-byte swap on a slot straddling a 4 KiB page boundary - slot at 0x7feba1402ffc: offset % 4096 = 4092, offset % 64 = 60 — the 8 bytes span two pages - children: pid 28925 exit 0, pid 28926 exit 0 - misaligned unlocked reader: 2619302 reads, 0 TORN values -[ OK ] D misaligned (page-straddling) unlocked reads :: no tearing observed on this CPU, but the ISA gives no guarantee for a misaligned access — keep the alignment invariant - -Done. diff --git a/spikes/c1/out/S17_closures_across_fork-8.4.log b/spikes/c1/out/S17_closures_across_fork-8.4.log deleted file mode 100644 index 65dff67..0000000 --- a/spikes/c1/out/S17_closures_across_fork-8.4.log +++ /dev/null @@ -1,54 +0,0 @@ -=== S17 — closures across fork === -PHP 8.4.19 (Linux), ZTS=no, pid=28861 -z-engine: booted (/home/user/z-engine) - ---- (a) closures compiled PRE-fork, invoked concurrently by two children - static closure: zend_closure at 0x7fe69e729280, handle 24, fn_flags=0x82402110 (HEAP_RT_CACHE=false), op_array.opcodes=0x7fe6a266b500 - use closure: zend_closure at 0x7fe69e729400, handle 23, fn_flags=0x82402100 (HEAP_RT_CACHE=false), op_array.opcodes=0x7fe6a266b780 - bound closure: zend_closure at 0x7fe69e729580, handle 26, fn_flags=0x82422101 (HEAP_RT_CACHE=false), op_array.opcodes=0x7fe6a26a5200 - children: pid 28862 exit 0, pid 28863 exit 0 (0.02 s, 100000 invocations each of 3 closures) - child 0: 0 wrong results, checksum 55100050000, closure spl_object_id 24 - child 1: 0 wrong results, checksum 55100050000, closure spl_object_id 24 -[ OK ] (a) pre-fork closures invoke correctly and identically in both children :: op_array, literals and the captured statics are all COW-shared read-only data - run_time_cache is per-closure heap memory (ZEND_ACC_HEAP_RT_CACHE): each child COW-copies its own - ---- (b) closure created POST-fork in child A, its address handed to child B over a pipe - child A built the closure at 0x7fe69e729700; in child A it returns 987654322 for input 1 - child B: pid 28865 killed by signal 11 (SIGSEGV) - markers: reached=1 materialized=1 is_object=1 is_Closure=1 invoked=1 survived=0 result=0 -[ OK ] (b) invoking a sibling-built closure by address is UNSAFE :: child B died with signal 11 (SIGSEGV) - every post-fork allocation lands on a private COW page; addresses are only meaningful - inside the process that allocated them. Closures therefore cannot be shared by address. - ---- (c) every pointer a zend_closure carries (feasibility of arena-cloning) - field address notes - zend_closure (whole struct) 0x7fe69e729700 - std.ce (Closure class entry) 0x563745285380 - std.handlers 0x56370db2ab80 - func.op_array.function_name 0x7fe6a265c960 - func.op_array.scope 0x0 (null) - func.op_array.arg_info 0x7fe6a2670960 - func.op_array.attributes 0x0 (null) - func.op_array.run_time_cache__ptr 0x7fe69e7ceec8 - func.op_array.opcodes 0x7fe6a2671780 - func.op_array.static_variables 0x7fe69e7e6a10 - func.op_array.static_variables_ptr__ptr 0x7fe69e7e6a10 - func.op_array.vars 0x7fe6a265d0a8 - func.op_array.refcount 0x7fe6a265e180 - func.op_array.literals 0x7fe6a26718c0 - func.op_array.filename 0x7fe6a265c140 - func.op_array.dynamic_func_defs 0x0 (null) - func.op_array.live_range 0x7fe6a265e190 - func.op_array.try_catch_array 0x0 (null) - this_ptr.value 0x0 (null) - called_scope 0x0 (null) - counts: num_args=1 last_var=3 T=3 last(opcodes)=10 last_literal=1 cache_size=0 num_dynamic_func_defs=0 - byte cost of a deep clone: opcodes 10*32=320, literals 1*16=16, vars 3*8=24, arg_info 1*32=32 -[ OK ] (c) pointer inventory taken :: 14 of 20 zend_closure/op_array pointer fields are non-NULL for a trivial closure - child sees the SAME closure at 0x7fe69e729700 (opcodes 0x7fe6a2671780, literals 0x7fe6a26718c0, static_variables 0x7fe69e7e6a10, handle 30) -[ OK ] (c) a pre-fork closure keeps identical addresses in the child - run_time_cache__ptr and static_variables_ptr__ptr point into the REQUEST arena, not into - the compiled op_array: an arena-resident closure would share those per-request slots - between processes. Any closure design must re-mint them per process. - -Done. diff --git a/spikes/c1/out/S17_closures_across_fork-8.5.log b/spikes/c1/out/S17_closures_across_fork-8.5.log deleted file mode 100644 index abd070f..0000000 --- a/spikes/c1/out/S17_closures_across_fork-8.5.log +++ /dev/null @@ -1,60 +0,0 @@ -=== S17 — closures across fork === -PHP 8.5.9 (Linux), ZTS=no, pid=28930 -z-engine: booted (/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/lib/../zengine-85) - ---- (a) closures compiled PRE-fork, invoked concurrently by two children - static closure: zend_closure at 0x7f2fe7e79c00, handle 24, fn_flags=0x82402110 (HEAP_RT_CACHE=false), op_array.opcodes=0x559cd24e0c70 - use closure: zend_closure at 0x7f2fe5e23900, handle 23, fn_flags=0x82402100 (HEAP_RT_CACHE=false), op_array.opcodes=0x559cd24e0f10 - bound closure: zend_closure at 0x7f2fe5e23780, handle 26, fn_flags=0x86422101 (HEAP_RT_CACHE=true), op_array.opcodes=0x559cd24d8cc8 - children: pid 28931 exit 0, pid 28932 exit 0 (0.02 s, 100000 invocations each of 3 closures) - child 0: 0 wrong results, checksum 55100050000, closure spl_object_id 24 - child 1: 0 wrong results, checksum 55100050000, closure spl_object_id 24 -[ OK ] (a) pre-fork closures invoke correctly and identically in both children :: op_array, literals and the captured statics are all COW-shared read-only data - run_time_cache is per-closure heap memory (ZEND_ACC_HEAP_RT_CACHE): each child COW-copies its own - ---- (b) closure created POST-fork in child A, its address handed to child B over a pipe - child A built the closure at 0x7f2fe7e79780; in child A it returns 987654322 for input 1 -PHP Fatal error: Uncaught TypeError: spl_object_id(): Argument #1 ($object) must be of type object, null given in /tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php:113 -Stack trace: -#0 /tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php(113): spl_object_id() -#1 /tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php(177): {closure:/tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php:99}() -#2 {main} - thrown in /tmp/claude-0/-home-user/75383dfd-1707-5f11-8f46-f7634c5ec618/scratchpad/spikes/S17_closures_across_fork.php on line 113 - child B: pid 28934 exit 255 - markers: reached=1 materialized=1 is_object=1 is_Closure=1 invoked=1 survived=0 result=0 -[ OK ] (b) invoking a sibling-built closure by address is UNSAFE :: child B invoked it and got 0 instead of 987654322 - every post-fork allocation lands on a private COW page; addresses are only meaningful - inside the process that allocated them. Closures therefore cannot be shared by address. - ---- (c) every pointer a zend_closure carries (feasibility of arena-cloning) - field address notes - zend_closure (whole struct) 0x7f2fe7e79780 - std.ce (Closure class entry) 0x559d05e38b20 - std.handlers 0x559cde7cf520 - func.op_array.function_name 0x559cd1e67b70 - func.op_array.scope 0x0 (null) - func.op_array.arg_info 0x559cd24e2010 - func.op_array.attributes 0x0 (null) - func.op_array.run_time_cache__ptr 0x7f2fe5e2ca98 - func.op_array.opcodes 0x559cd24e1ef0 - func.op_array.static_variables 0x559cd24e1eb8 - func.op_array.static_variables_ptr__ptr 0x7f2fe7f57e00 - func.op_array.vars 0x559cd24e2040 - func.op_array.refcount 0x0 (null) - func.op_array.literals 0x0 (null) - func.op_array.filename 0x559cd24d8b08 - func.op_array.dynamic_func_defs 0x0 (null) - func.op_array.live_range 0x559cd24e2030 - func.op_array.try_catch_array 0x0 (null) - this_ptr.value 0x0 (null) - called_scope 0x0 (null) - counts: num_args=1 last_var=3 T=2 last(opcodes)=8 last_literal=0 cache_size=8 num_dynamic_func_defs=0 - byte cost of a deep clone: opcodes 8*32=256, literals 0*16=0, vars 3*8=24, arg_info 1*32=32 -[ OK ] (c) pointer inventory taken :: 12 of 20 zend_closure/op_array pointer fields are non-NULL for a trivial closure - child sees the SAME closure at 0x7f2fe7e79780 (opcodes 0x559cd24e1ef0, literals 0x0, static_variables 0x559cd24e1eb8, handle 27) -[ OK ] (c) a pre-fork closure keeps identical addresses in the child - run_time_cache__ptr and static_variables_ptr__ptr point into the REQUEST arena, not into - the compiled op_array: an arena-resident closure would share those per-request slots - between processes. Any closure design must re-mint them per process. - -Done. diff --git a/spikes/c1/run-all.sh b/spikes/c1/run-all.sh deleted file mode 100755 index f241b69..0000000 --- a/spikes/c1/run-all.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Runs every spike on every supported PHP minor and stores the logs under out/. -# -# ./run-all.sh # php8.4 and php8.5 -# ./run-all.sh php8.4 # one binary -set -u - -cd "$(dirname "$0")" || exit 1 -mkdir -p out - -BINS=("$@") -if [ ${#BINS[@]} -eq 0 ]; then - BINS=(php8.4 php8.5) -fi - -SPIKES=( - S12_cross_process_mutation.php - S13_shared_ardata.php - S14_attach_side_effects.php - S16_string_swap.php - S17_closures_across_fork.php - S08_S15_mutex_and_bump.php -) - -for bin in "${BINS[@]}"; do - ver=$("$bin" -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;') - for spike in "${SPIKES[@]}"; do - log="out/${spike%.php}-${ver}.log" - printf '=== %s on %s -> %s\n' "$spike" "$bin" "$log" - timeout 900 "$bin" -d ffi.enable=1 -d opcache.jit=off "$spike" > "$log" 2>&1 - printf ' exit %d, %d OK / %d FAIL\n' "$?" \ - "$(grep -c '^\[ OK \]' "$log")" "$(grep -c '^\[FAIL\]' "$log")" - done -done diff --git a/spikes/c1/verdicts.md b/spikes/c1/verdicts.md deleted file mode 100644 index 4de3b0d..0000000 --- a/spikes/c1/verdicts.md +++ /dev/null @@ -1,459 +0,0 @@ -# Spike verdicts — zero-serialization shared-object arena - -Agent **C1** (spike/validation). EPIC: [php-shared-data-extension#15](https://github.com/lisachenko/php-shared-data-extension/issues/15). - -Everything here was run **on both supported minors** and every check is green on both: - -| | PHP 8.4.19 (NTS) | PHP 8.5.9 (NTS) | -|---|---|---| -| S12 cross-process mutation | 9 OK / 0 FAIL | 9 OK / 0 FAIL | -| S13 pre-sized arData | 10 / 0 | 10 / 0 | -| S14 attach side effects | 8 / 0 | 8 / 0 | -| S16 string swap | 5 / 0 | 5 / 0 | -| S17 closures across fork | 4 / 0 | 4 / 0 | -| S8/S15 mutex + bump | 6 / 0 | 6 / 0 | - -**Headline: the premise holds.** An engine-formatted `zend_object` placed in -`MAP_SHARED|MAP_ANONYMOUS` memory can be attached as an ordinary PHP instance in several -forked processes at once, and a plain `$obj->prop = ...` in one process is immediately -visible to the others. Everything below is about the sharp edges around that fact. - ---- - -## How to reproduce - -``` -spikes/ - lib/bootstrap.php harness: PSR-4 autoload for ZEngine\ + Lisachenko\SharedData\, - libc FFI binding (mmap / robust pshared mutexes), fork helpers - S12_cross_process_mutation.php - S13_shared_ardata.php - S14_attach_side_effects.php - S16_string_swap.php - S17_closures_across_fork.php - S08_S15_mutex_and_bump.php - run-all.sh runs everything on php8.4 + php8.5, logs into out/ - out/*.log captured evidence for the numbers quoted below - zengine-85/ shallow clone of z-engine `master` (the 8.5.x-dev line) -``` - -```bash -./run-all.sh # both minors -php8.4 -d ffi.enable=1 -d opcache.jit=off S12_cross_process_mutation.php -``` - -**Environment note the implementing agents need:** the checkout at `/home/user/z-engine` is -the **8.4 branch only** (`SUPPORTED_PHP_VERSION_ID = [80400, 80500)`, `include/8.4` only), so -running any z-engine-backed code under php8.5 against it aborts in `Core::init()`. The spikes -resolve the 8.5 line from a scratch clone of z-engine `master` (`spikes/zengine-85`). This is -a sandbox artifact, not a design finding — but any CI leg that exercises E1–E5 on 8.5 needs -Composer to actually resolve `8.5.x-dev`, and a local path repo pointing at the 8.4 checkout -will silently skip instead of failing. - -Structural fact worth recording: `zval` (16), `Bucket` (32), `zend_array` (56), -`zend_object` (56 + 16·(n-1)) and the whole `zend_op_array` field order are **byte-identical -between 8.4 and 8.5** on linux-x64-nts (`diff` of the two generated `engine.h` core sections -is empty). Nothing in the arena layout has to be versioned per minor beyond what z-engine -already versions. - ---- - -## S12 — cross-process mutation visibility · **GREEN** - -### Evidence - -**A. Hand-built zval slot, 10⁶ locked writes, 1 locked reader + 1 unlocked reader** - -``` -A1 locked reader: 1090181 reads, 0 inconsistent observations - locked reader last observed generation 1000000 of 1000000 -A2 unlocked reader: 8615400 reads, 182045 value/mirror mismatches, - 116039 value-vs-type mismatches -``` -(8.5: 977876 / 0, and 289045 + 139907 mismatches.) - -**B1. The motivating negative result — today's malloc path** - -``` -persistent clone at 0x55b1314e5e20, object size 88 bytes (malloc/pemalloc heap) -child wrote counter=424242 (child read back 424242) -[ OK ] B1 parent still sees counter=100 - CONFIRMED: malloc memory is COW across fork — mutations are NOT shared -``` - -**B2. The same object memcpy'd into the arena and re-anchored** - -``` -arena object at 0x7f5ede601000, handle 36, spl_object_id=36, class=S12Holder -LOCKED reader: 169107 reads, 0 inconsistent, highest counter observed 200000 of 200000, - max value age 166.4 us -UNLOCKED reader: 2607122 reads, 89409 inconsistent (3.43%) -parent now reads counter=200000 ratio=50000.0 flag=false (written only by a child) -``` - -**C. Reverse direction (E1 acceptance #2).** A child bump-allocated a *brand new* -`S12Holder` into the arena post-fork, applied the `persistentClone` GC surgery to the arena -block, and sent 8 bytes down a pipe. The parent attached it after the child had exited and -read `counter=31337 ratio=2.5 flag=true`. - -### Consequences - -- **E1 (#16):** the arena + bump-allocate + publish-address-over-a-pipe path works end to - end, in both directions, on both minors. `PersistentObjectFactory::persistentClone()`'s GC - surgery (`PIN_BASELINE`, `GC_OBJECT|GC_NOT_COLLECTABLE|GC_PERSISTENT`, - `IS_OBJ_DESTRUCTOR_CALLED|IS_OBJ_FREE_CALLED`, `handlers = std_object_handlers`, - `properties = NULL`) is exactly right for an arena block too — the only thing the Z1 - allocator seam has to change is *where the bytes come from*. -- **E2 (#17):** scalar in-place property writes are visible cross-process **immediately** — - no flush, no barrier, no re-attach. Under the stripe lock a reader observed the writer's - value at most **~110–210 µs old** (max over ~170k locked reads; that is lock-wait plus - scheduler latency, not a memory-visibility delay). -- **E2/E3 — design correction:** *a 16-byte zval is not atomic.* The value word and the - `u1.type_info` word are two separate stores, and an unlocked reader observed the two - halves from different generations **116 039 times in 8.6 M reads (~1.3 %)**. At the PHP - level a 3-property update was observed half-applied in **2.7–3.8 %** of unlocked reads. - Readers **must take the same stripe lock as the writer** whenever the *type* can change - or more than one slot participates. This lands directly on E3's "16-byte tagged record" - contract: a `SharedChannel` ring slot **cannot** be published with a plain store of the - record — publish the payload first, then the tag, with the tag store as the release point, - or keep the whole ring operation under the ring mutex (recommended for v1). - ---- - -## S13 — pre-sized arData in shared memory · **GREEN (with a hard trap, documented)** - -A 64-entry hash `zend_array` was built with `PersistentHashTable`, sealed with -`markImmutable()`, and relocated into the arena — struct **and** the single engine data -block, with `arData` re-pointed: - -``` -nTableMask=-128 HT_HASH_SIZE=512 HT_DATA_SIZE=2048 one block of 2560 bytes -arena table: struct at ...400, block at ...1000, arData at ...1200 (inside arena: true) -zval type_info = 0x7 (GC_IMMUTABLE => non-refcounted IS_ARRAY) -``` - -The relocation arithmetic implementers need (mirrors `zend_types.h`): - -``` -HT_HASH_SIZE(nTableMask) = (uint32_t)(-(int32_t)nTableMask) * sizeof(uint32_t) -HT_DATA_SIZE(nTableSize) = nTableSize * sizeof(Bucket) // 32 bytes -HT_GET_DATA_ADDR(ht) = (char*)ht->arData - HT_HASH_SIZE(ht->nTableMask) -``` -`nTableMask` is declared `uint32_t` but is used signed — read it signed or the hash size -comes out astronomically wrong. - -### Evidence - -- `count()` = 64, `$a['k7']` = 70, `array_sum()` over `foreach` = 20160, and z-engine's own - `HashTable` view agrees — all through a real PHP array zval pointing at arena memory. -- Concurrent load: one child overwrote `bucket[7].val` in place (raw `lval` + `IS_LONG`) - 200 000 times under the mutex while two siblings read. - ``` - value reader: 157828 locked reads, 0 anomalies, highest 200000 of 200000 - structural reader: 78054 full foreach+count walks, 0 anomalies - ``` -- **The trap.** A child inserted past capacity into a *non-sealed* arena table: - ``` - free(): invalid pointer - growth child: killed by signal 6 (SIGABRT) - parent now reads ht->arData = 0x5575f5e9b1c0 (inside arena2: false) - post-growth foreach child: exit 0 - it walked 8 entries summing to 280; the shared struct claims nNumOfElements=8 nTableSize=16 - ``` - Two distinct failures in one event: (1) the resize `pefree()`s the *old* block, which is - arena memory the process allocator never handed out → **SIGABRT**; (2) before aborting the - engine had already written the new `arData` — a pointer into that child's private heap — - **into the shared struct**, so a surviving sibling walks a table that looks perfectly - healthy and returns **silent garbage, with no signal at all**. - -### Consequences - -- **E1 (#16):** the "registry tables never grow via the engine" guard is not a nicety, it is - the difference between a crash and silent corruption. Guard shape that works: record - `arData` at seal time and assert on every access that `HT_GET_DATA_ADDR(ht)` is still - inside the arena bounds — the pointer change is cheap to detect and is the *only* - observable symptom in the silent case. Pre-size with the Z1 external-arData API and never - hand a growable table to userland. -- **E2 (#17):** confirms "plain-array property mutation stays forbidden". In-place *value* - overwrite of an existing bucket is safe and fast; anything that can trigger - `zend_hash_do_resize` (insert, `zend_hash_add`, packed→hash conversion) is not. -- **E3 (#18):** `SharedArray` as a **fixed-capacity vector of 16-byte records** rather than a - wrapped `zend_array` is the right call; this spike is the evidence for why. -- Bucket **keys** in a relocated table still point at malloc-interned `zend_string`s. That - survives fork by COW but would not survive a non-forked attach — arena-intern keys too - (S16 shows the mechanics). - ---- - -## S14 — per-process side effects of attach · **RED for the current field layout; the side table is mandatory** - -### A. `obj->handle` is clobbered - -``` -parent put() -> handle 35, obj->handle=35, spl_object_id=35 -child 0: put() returned handle 36 ... child 1: put() returned handle 36 -parent afterwards: obj->handle=36, spl_object_id($parentInstance)=36 (parent's real slot is 35) -parent's object store slot 36 currently holds: a DIFFERENT live object -``` - -Note the detail that makes this worse than a race: both children were handed **the same -handle number 36**, because each inherited the same COW'd `EG(objects_store).free_list_head`. -Handles are not merely clobbered, they *collide by construction*. After the children ran, -the parent's `spl_object_id()` returns a slot number belonging to someone else, and -`ObjectStore::recycle()` at detach would push a **foreign** slot onto the free list. - -### B. `obj->properties` — the dynamic-properties pointer hazard - -``` -inside child A: properties 0x0 -> get_object_vars(2 vars) -> 0x7f81c76c4310 - -> var_dump -> same -> json_encode -> same -> (array) cast -> same -PARENT now reads obj->properties = 0x7f81c76c4310 (child A is gone; that is child A's private heap) -``` - -A single `get_object_vars()` — an operation that reads like a pure read — writes a -request-heap `HashTable*` into the shared struct, and it stays there. Two follow-ups: - -- a **forked sibling** survived but got the wrong answer: `get_object_vars()` returned - **1 property instead of 2**, `var_dump()` printed a 49-byte dump. Silent garbage, no signal - — because fork gave it the same COW heap layout, so the address happened to be mapped. -- a process that did **not** inherit that heap (simulated by pointing `properties` at an - address mapped nowhere) died with **SIGSEGV on both 8.4 and 8.5**. - -### C. `obj->ce` and `obj->handlers` - -``` -parent: std_object_handlers=0x556beb7b5920, S14Holder ce=0x7fec5b604018 -child 0: handlers=SAME S14Holder ce=SAME post-fork S14LateClass ce=0x7fec5747d368 -child 1: handlers=SAME S14Holder ce=SAME post-fork S14LateClass ce=0x7fec57476928 -``` - -`std_object_handlers` is address-identical in every forked process — **safe to keep inside -the shared struct**, exactly as E2 assumes. A class entry loaded **before** the fork is also -address-identical. But a class first declared **after** the fork lands at a different address -in each process as soon as their compile histories differ (child 0 declared 40 decoy classes -first). So `ce` is fork-stable **only** for the pre-fork-loaded subset. - -### Consequences - -- **E2 (#17), item 2 — confirmed necessary and correctly scoped.** `handle`, `ce`, - `properties` out of the shared struct into a per-process side table keyed by arena address; - `handlers` stays. Add these concrete requirements: - - **`properties` must be forced back to `NULL` in the shared struct** on every attach, and - the object must be barred from ever caching a rebuilt bag there. The cheapest correct - shape is a `get_properties_for`/`get_debug_info` handler pair on the shared class that - builds a *request-local* table and never writes `obj->properties` — otherwise the hazard - is re-armed by the first `var_dump()` any worker ever runs. A "children never write the - shared struct" rule is **not** enough here: the write is performed by engine C code - inside `zend_std_get_properties`, not by our code. - - The full trigger list observed: `get_object_vars()`, `var_dump()`, `json_encode()`, - `(array)` cast. Also reachable via `serialize()`, `debug_zval_dump()`, - `ReflectionObject::getProperties()`, object iteration, `property_exists()` on dyn props. - - `spl_object_id()` reads `obj->handle` **directly** — it cannot be fixed by a side table - alone. If per-process identity matters to user code, the shared class needs its own - identity story (document it, or expose `Arena::idOf($obj)`), because - `spl_object_id()`/`spl_object_hash()` on a shared object will be whatever the last - attaching process wrote. - - `attach()` must **not** call `zend_objects_store_put` on a struct another process may be - attaching concurrently. Either serialize attach under the object's stripe lock and - immediately restore the field from the side table, or (better) stop letting the engine - write it at all: `put()` then rewrite `obj->handle` back to a sentinel and serve the real - handle from the side table. -- **E1 (#16):** the registry must key everything by **arena address**, never by handle — - handles are not stable, not unique, and not even distinct between processes. - ---- - -## S16 — string swap visibility · **GREEN** - -Two `zend_string`s were interned into the arena (via -`StringEntry::persistentInterned()` + memcpy — the `GC_IMMUTABLE|IS_STR_INTERNED` header is -already the shape a shared string needs: engine copies it into zvals without refcounting and -copy-on-writes on mutation, so no process ever bumps a refcount or frees it in shared -memory). A shared object's `string $name` slot was pointed at string A, then swapped -300 000 times between A and B. - -``` -LOCKED reader: 267587 reads (A=135833 B=131754), 0 torn, max staleness 205.7 us -UNLOCKED reader: 2022502 reads, 0 torn pointers, 0 unexpected string values, - max staleness 207.8 us -``` -(8.5: 251818 / 0 torn, 2 616 769 unlocked reads / 0 torn.) - -Control: the same swap on an 8-byte slot **straddling a 4 KiB page boundary** produced no -tearing on this CPU over 1.5–2.8 M reads either — the ISA still gives no guarantee there, so -the alignment invariant stays, it just isn't cheaply falsifiable on this hardware. - -### Consequences - -- **E2 (#17):** the "string property = arena-intern new bytes + pointer swap under lock" - contract is sound. Concretely: **a naturally-aligned 8-byte pointer swap never tears**, so - the lock is buying *ordering between slots* and *lifetime safety*, not per-pointer - atomicity. A single-string-slot reader may legitimately read without the lock and will get - either the old or the new string, never a mix — useful for hot read paths, and worth - stating explicitly so nobody adds locking that isn't needed. -- Stale reads without the lock are bounded by the writer's publish rate, not by anything - architectural: the maximum age of the value an unlocked reader saw was **~208 µs**, - statistically identical to the locked reader's. Taking the lock does **not** make a reader - fresher; it makes a *multi-slot* read consistent. -- Lifetime rule the numbers imply: the swapped-away string must **not** be freed. With - leak-until-teardown v1 that is automatic; if a reclaimer ever appears, an unlocked reader - holding the old pointer is the hazard to design against. -- Keep every arena `zval` 8-byte aligned at minimum (the natural `zend_object` layout gives - `properties_table[i]` at `40 + 16i`, which is 8-aligned when the object block is - 16-aligned — bump-allocate objects 16-aligned and this is free). - ---- - -## S17 — closures across fork · **GREEN for Phase A, AMBER for Phase B** - -### (a) Pre-fork closures — safe - -Static closure, closure with `use` scalars, and a `$this`-bound closure, each invoked -100 000 times concurrently in two children: - -``` -child 0: 0 wrong results, checksum 55100050000, closure spl_object_id 24 -child 1: 0 wrong results, checksum 55100050000, closure spl_object_id 24 -``` - -### (b) Post-fork closure invoked by a sibling — unsafe, both failure modes captured - -Child A allocated ballast, built a closure, and sent its address down a pipe; child B -materialized an `IS_OBJECT` zval at that address and invoked it. - -- **8.4:** `child B: killed by signal 11 (SIGSEGV)` — markers show it got as far as - `$alien instanceof \Closure === true` and died inside the invoke. -- **8.5:** child B invoked **a completely different function** — the address held the spike's - own fork-body closure, which ran with its captured variables `null` and died with - `TypeError: spl_object_id(): Argument #1 must be of type object, null given`. - -The 8.5 outcome is the more instructive one: the address *was* a live `Closure` in child B, -just not the intended one. There is no validity check that could have caught it. - -### (c) Pointer inventory of a `zend_closure` - -For a trivial `function (int $x) use ($base) { static $calls = 0; ... }`, **14 of 20** -pointer fields are non-NULL on 8.4 (12 on 8.5): - -| field | 8.4 | note | -|---|---|---| -| `std.ce` / `std.handlers` | set | process-stable (Closure is an internal class) | -| `op_array.opcodes` | set | 10 ops × 32 B = 320 B | -| `op_array.literals` | set | 1 × 16 B (NULL on 8.5 for this closure) | -| `op_array.vars` | set | 3 × 8 B | -| `op_array.arg_info` | set | 1 × 32 B | -| `op_array.function_name`, `.filename` | set | `zend_string*` | -| `op_array.refcount` | set (8.4) / NULL (8.5) | shared op_array refcount | -| `op_array.live_range` | set | | -| `op_array.static_variables` | set | a `HashTable*` | -| `op_array.static_variables_ptr__ptr` | set | **per-request slot** | -| `op_array.run_time_cache__ptr` | set | **per-request slot** | -| `op_array.scope`, `.attributes`, `.dynamic_func_defs`, `.try_catch_array` | NULL | for this closure | -| `this_ptr`, `called_scope` | NULL | set for bound closures | - -A pre-fork closure keeps **identical addresses** in the child (verified: -struct, `opcodes`, `literals`, `static_variables` all match). - -### Consequences - -- **E5 (#20) Phase A — GREEN, ship it.** Pre-fork closures are safe to persist by address and - to transport as `OBJ` records. The op_array, literals and captured statics are read-only - COW data; `run_time_cache` is per-process private after fork (each child COW-copies its own - page on first write), which is exactly why (a) is correct. -- **E5 Phase B — AMBER, and the blocker is not the op_array bytes.** Deep-cloning the - *compiled* graph is small and tractable: ~400 bytes for the closure above, and every field - is enumerable through z-engine. The blocker is that **`run_time_cache__ptr` and - `static_variables_ptr__ptr` point into the request arena, not into the op_array**. Put a - closure struct in the arena and those two slots become *shared*, so two processes would - write each other's polymorphic-cache entries and each other's `static` variables. Any - Phase B design must re-mint both per process — which is a per-process side table for - closures, structurally the same mechanism E2 builds for objects. Recommend: implement - Phase A now, and scope Phase B as "arena-clone the immutable compiled graph + per-process - cache/statics side table", or record the documented not-soundly-achievable verdict that - #20's acceptance criteria already allow for. -- **E3 (#18):** the typed rejection for post-fork closures must be **unconditional and - address-based** (was this closure compiled before the fork barrier?). It must not be a - "does this look like a Closure" check — S17(b) on 8.5 shows a wrong address passing every - plausible validity test and then executing the wrong function. - ---- - -## S8 / S15 — quick confirmations (X1 owns the in-repo versions) · **GREEN** - -### S8 — robust pshared mutex, owner died - -``` -holder: killed by signal 9 (SIGKILL) -parent pthread_mutex_lock() returned 130 after 6.5 us (EOWNERDEAD == 130) -consistent()=0 unlock()=0 then lock()=0 unlock()=0 -``` - -Two controls, both of which the implementation must encode as rules: - -- **skipping `pthread_mutex_consistent()` is fatal and permanent**: unlock without it and the - next `lock()` returns **131 = ENOTRECOVERABLE**, forever, for every process. A missed - recovery handler takes the whole arena down, not just one critical section. -- a **non-robust** pshared mutex whose owner dies is simply stuck: `trylock()` returns - **16 = EBUSY** and `lock()` would block forever. `PTHREAD_MUTEX_ROBUST` is not optional. - -Layout confirmed as assumed: glibc x86-64 `pthread_mutex_t` = 40 bytes, -`pthread_mutexattr_t` = 4; the spikes use 64-byte slots (one cache line) and that works -cleanly with `FFI::cdef(..., null)` resolving libc through the process image. - -### S15 — concurrent bump allocation, 4 children - -``` -S15a (under the mutex): 100000 records, 11199712 bytes carved, - 0 overlaps, 0 duplicate offsets, 0 corrupted blocks -S15b (no mutex): 82309 records (expected 100000), 2031 overlaps, - 765 duplicate offsets, 3124 corrupted blocks -``` - -Verification is not just interval arithmetic: every block was `memset` with its owner's tag -and re-read afterwards, so an overlap shows up as *content* corruption too. - -### Consequences - -- **E1 (#16):** the stripe-mutex bank design is sound. Add to the acceptance criteria: - **every `pthread_mutex_lock()` call site must handle `EOWNERDEAD`** — check the return - code, run the invariant repair for that stripe, call `pthread_mutex_consistent()`, and only - then proceed. A wrapper that ignores the return value is a latent arena-wide deadlock. It - is worth a debug assertion that no lock helper discards its `int` result. -- Because a worker can die mid-critical-section, the E2 rule "critical sections are memcpys - and pointer swaps only, no engine calls that allocate, no user callbacks, no Fiber - suspension" is what makes `EOWNERDEAD` recovery *possible at all*: a section that can only - be half-done in a bounded, structurally checkable way is one you can repair. Keep that rule - enforced by assertion, as #17 already plans. - ---- - -## Consolidated design corrections for the implementing agents - -1. **A 16-byte zval is not atomic.** Value and `type_info` are separate stores; ~1.3 % of - unlocked reads observed mismatched halves over 8.6 M samples. Type-changing writes and - multi-slot updates require the stripe lock on **both** sides. (E2, E3 tagged records.) -2. **An aligned 8-byte pointer swap is atomic.** Single-slot string/object-reference reads - may skip the lock; they get old-or-new, never a mix. Don't over-lock hot read paths. (E2) -3. **`obj->properties` is written by engine C code on read-shaped operations.** A - "we never write it" policy cannot hold it. Force it `NULL` and intercept - `get_properties_for`/`get_debug_info` on the shared class, or a single `var_dump()` in one - worker segfaults the next one. Confirmed SIGSEGV on 8.4 and 8.5. (E2) -4. **`obj->handle` collides, it does not merely race.** Forked children inherit the same - object-store free list and hand out the *same* handle. `spl_object_id()` on a shared - object is unreliable by construction — decide and document the identity story. (E2) -5. **`obj->ce` is fork-stable only for pre-fork-loaded classes.** Two workers that autoload - in different orders place the same class at different addresses. Side-table it; keep only - `handlers` in the shared struct. (E2) -6. **Table growth is silent, not loud.** A resize writes a private-heap `arData` into the - shared struct *before* it aborts; siblings then read plausible garbage with no signal. - Bounds-check `HT_GET_DATA_ADDR(ht)` against the arena on access — the pointer change is - the only observable symptom. (E1, E3) -7. **`EOWNERDEAD` must be handled at every lock site.** Skipping `pthread_mutex_consistent()` - poisons the mutex with `ENOTRECOVERABLE` permanently, arena-wide. (E1) -8. **Post-fork closures cannot be validated by inspection.** On 8.5 a stale address held a - *different, perfectly valid* `Closure` and executed it. Reject on provenance (compiled - before the fork barrier?), never on shape. (E5, E3) -9. **Phase B's real cost is not the op_array.** ~400 bytes of enumerable compiled data; the - blocker is `run_time_cache__ptr` and `static_variables_ptr__ptr` pointing into the request - arena. Arena-resident closures share those per-request slots between processes — they must - be re-minted per process. (E5) From 1cbc8bbfeb4b53cb939591288e5a4e162583a9fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:41:03 +0000 Subject: [PATCH 20/27] docs: distill the shared-memory model, limitations and evidence from the validation sweep The knowledge the removed sweep carried is worth keeping; its harness was not. This is the same material as a repository document: the laws (fork-only sharing, what is atomic and what only looks atomic, the three per-process fields, engine table growth as silent corruption, robust-mutex handling, leak-until-teardown accounting, closure provenance) with the symptom each one produces when it is violated, since almost none of them raise. Written for a consumer of the package rather than for the sweep that produced it, with the measurements cited back to the spike-gate record on the epic. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- README.md | 3 +- docs/shared-memory-model.md | 213 ++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 docs/shared-memory-model.md diff --git a/README.md b/README.md index 018b7b2..3a41c47 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,8 @@ of the next iteration fixes, and all of them are spelled out on `bootShared()`): 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. -The evidence for every claim in this section is in `spikes/`. +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) diff --git a/docs/shared-memory-model.md b/docs/shared-memory-model.md new file mode 100644 index 0000000..1cf392f --- /dev/null +++ b/docs/shared-memory-model.md @@ -0,0 +1,213 @@ +# 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 (a child unmapping the region would +pull memory out from under its parent and siblings, so `destroy()` in a child is a deliberate +no-op). + +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. 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. + +## 9. 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; the synchronized path is the explicit write API | +| 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.* From a02eda5ba6339cafae15c1b1a0ca642fff4d5d4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:50:00 +0000 Subject: [PATCH 21/27] docs: describe how the shared-memory solution is built, not only what constrains it Adds the implementation map to the model document: what each primitive is (arena and its allocator seam, registry, per-process side table, the mutation path, value records and the IPC primitives), and which of the laws above it exists to satisfy. The document now answers all three questions in order - what works, why it is done this way, how it is implemented. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- docs/shared-memory-model.md | 83 ++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/docs/shared-memory-model.md b/docs/shared-memory-model.md index 1cf392f..a4a41b9 100644 --- a/docs/shared-memory-model.md +++ b/docs/shared-memory-model.md @@ -179,7 +179,86 @@ objects (data), not as callables. [#20]: https://github.com/lisachenko/php-shared-data-extension/issues/20 -## 8. Structural notes worth knowing +## 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 @@ -192,7 +271,7 @@ objects (data), not as callables. *after* the fork and hand its address to the parent, which attaches it after the child has exited. -## 9. Known limitations, and where they are tracked +## 10. Known limitations, and where they are tracked | Limitation | Status | |---|---| From 1df8fe009927fdf103f972a08218e2398457aa92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:55:09 +0000 Subject: [PATCH 22/27] fix(shm): refuse every free of arena memory at the last line before it The registry already routes arena-backed state past the reclaimer, but that is one decision at one call site, and freeing shared memory is not a mistake that announces itself: it corrupts the heap of the process that calls it and leaves every sibling reading memory nobody owns. A forked child is the dangerous case - it inherits every pointer its parent had and owns none of the memory behind them. So the refusal moves to the last line before the free, where both the table and the block paths pass, and it is armed by the arena itself when the shared store boots. The message names the role of the process, because "this is a child" is usually the whole diagnosis. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/Reclaimer.php | 66 ++++++++++++++++++++++++++++++++++++++ src/Shm/ArenaException.php | 21 ++++++++++++ 2 files changed, 87 insertions(+) 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/Shm/ArenaException.php b/src/Shm/ArenaException.php index 59e65fd..c60effe 100644 --- a/src/Shm/ArenaException.php +++ b/src/Shm/ArenaException.php @@ -188,6 +188,27 @@ public static function foreignArena(int $expected, int $found): self )); } + /** + * 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'); From d2d49c56bd3f34e5f95d7d5518bb8801735dc3ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:55:09 +0000 Subject: [PATCH 23/27] feat(registry): record whether a persisted object belongs to a mutable graph Frozen and shared-mutable objects need opposite lifecycles - one is restored from its snapshot at request end, the other must never be - and the decision cannot live in the process that persisted it: a sibling attaching the same address later has nothing but the registry to learn it from. So the role travels in the object record, and the layout version moves with it (v5), because a worker that cannot read the role would apply frozen semantics to memory its siblings are writing. Also exposes the property-slot mapping the persister already computes: writing one property of a shared object means writing one SLOT of it, resolved against the class entry of the process doing the writing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/ObjectPersistenceModule.php | 2 +- src/PersistedObject.php | 7 +++++++ src/Persister.php | 17 +++++++++++++++++ src/Registry.php | 13 ++++++++++++- tests/Shm/ArenaRegistryTest.php | 8 +++++--- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/ObjectPersistenceModule.php b/src/ObjectPersistenceModule.php index 7d5f3bf..bf3d358 100644 --- a/src/ObjectPersistenceModule.php +++ b/src/ObjectPersistenceModule.php @@ -29,7 +29,7 @@ * 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 4), + * [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 * 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/Persister.php b/src/Persister.php index 583514c..6b27521 100644 --- a/src/Persister.php +++ b/src/Persister.php @@ -171,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 * diff --git a/src/Registry.php b/src/Registry.php index 938b597..2183869 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -41,6 +41,8 @@ * '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 * * ## Where those tables live: process heap, or the fork-shared arena * @@ -94,8 +96,11 @@ final class Registry * 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 = 4; + public const LAYOUT_VERSION = 5; /** * Sign correction for nTableMask, which the engine declares unsigned and uses signed @@ -400,6 +405,10 @@ private function addObject(PersistedObject $object): void 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(); @@ -432,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'); @@ -450,6 +460,7 @@ private static function hydrateObject(int $address, ReflectionValue $metaValue): $shares, $meta->getRawValue(), $arraysTable->getRawValue(), + $mutable === 1, ); } diff --git a/tests/Shm/ArenaRegistryTest.php b/tests/Shm/ArenaRegistryTest.php index 8e034be..76809c9 100644 --- a/tests/Shm/ArenaRegistryTest.php +++ b/tests/Shm/ArenaRegistryTest.php @@ -42,10 +42,12 @@ private function makeArena(): array return [$arena, new ArenaAllocator($arena)]; } - public function testLayoutVersionIsFour(): void + public function testLayoutVersionIsFive(): void { - // The version the module globals are checked against; arena tables are what v4 adds - $this->assertSame(4, Registry::LAYOUT_VERSION); + // 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 From 91f40c7d14c4ece55541551ef4b139ee2d5c533e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:55:33 +0000 Subject: [PATCH 24/27] feat(store): per-process side table and an opt-in mutable shared mode Two halves of the same problem: a zend_object in the arena is one struct read by several processes, and both its engine state and its lifecycle have to stop being per-process by accident. The side table takes the three fields that describe the READER rather than the object. `handle` collides by construction - forked children inherit one object-store free list and are handed identical numbers - so the real handle lives per process and the shared field is overwritten with a sentinel the store can never produce, which also makes spl_object_id() uselessly honest; sharedIdOf() returns the arena address, which is what every process agrees on. `ce` is rebound per process and recorded, the shared field being advisory. And `properties`, which engine C code writes on read-shaped operations, is forced NULL at attach and never dereferenced in shared mode: scrubProperties() drops the pointer unread, because reading its refcount would already be a dereference of somebody else's request heap, and inspect() brackets a var_dump()/json_encode() so the cache dies in the process that caused it. Mutable mode is opted into per graph. Everything that makes a persistent clone safe stays - the refcount pin, GC_PERSISTENT|GC_NOT_COLLECTABLE, bare non-refcounted payloads, sealed arrays - and what goes is frozen semantics: detach() never memcpys a request-old snapshot over slots three workers are writing. SharedObjectHandle is the synchronized way to write one: values are validated and interned BEFORE the object's stripe lock, and the critical section is the payload word and then the type word, because a 16-byte zval is two stores and an unlocked reader was measured to see the halves apart. Strings are interned into the arena and swapped as one aligned pointer (the old block leaks by design), references may only point at another object of the same arena, arrays stay sealed, and declared property types are enforced here because the engine never gets to check them. Direct `$obj->prop = ...` writes stay legal and unsynchronized - a class rewired to std_object_handlers offers no write hook, which is a trade rather than an oversight - so a slot found holding a pointer outside the arena at detach is repaired from the frozen image instead of being left for a sibling to follow. Finally, drop()'s alias predicate is disabled for shared graphs: a refcount in the arena is written by every worker that ever copied the value, and it guards memory that is never handed back anyway. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/PersistentStore.php | 534 ++++++++++++++++++++++++++++---- src/SharedMutationException.php | 156 ++++++++++ src/SharedObjectHandle.php | 530 +++++++++++++++++++++++++++++++ src/SideTable.php | 131 ++++++++ 4 files changed, 1292 insertions(+), 59 deletions(-) create mode 100644 src/SharedMutationException.php create mode 100644 src/SharedObjectHandle.php create mode 100644 src/SideTable.php diff --git a/src/PersistentStore.php b/src/PersistentStore.php index 6040cba..dcd7fec 100644 --- a/src/PersistentStore.php +++ b/src/PersistentStore.php @@ -22,6 +22,7 @@ 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) @@ -59,6 +60,22 @@ final class PersistentStore */ 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) @@ -75,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, ?ArenaAllocator $allocator = null) + /** + * 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($allocator); + $this->sideTable = new SideTable(); } /** @@ -152,30 +182,36 @@ public static function boot(string $moduleName = 'shared_objects'): self * 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. * - * ## What v1 of arena mode does NOT do yet (the E1/E2 boundary) - * - * Sharing the memory is one thing; sharing the ENGINE STATE that lives inside a - * zend_object is another, and three of its fields are per-process by nature: - * - * - **classes must be loaded before the fork.** A shared clone carries one `ce` slot - * for the whole family, and attach() rebinds it by name. That is only harmless while - * the class entry sits at the same address everywhere, which holds for classes loaded - * before the fork (opcache.preload, or simply touching them) and does not hold for a - * class first autoloaded inside one worker; - * - **`spl_object_id()` is not meaningful on a shared object.** The engine reads the - * handle out of the shared struct, and every process that attaches writes its own - * there - forked children even receive identical handle numbers, since they inherit - * one object-store free list. This store therefore keys everything by ARENA ADDRESS - * and keeps its handles in its own per-process table; - * - **avoid `get_object_vars()`, `var_dump()`, `json_encode()` and `(array)` casts on - * shared objects.** Engine C code caches the rebuilt property bag in the object's - * `properties` field - a request-heap pointer written into shared memory. detach() - * clears it again for this process, but a sibling reading it in the meantime is - * looking at foreign memory. - * - * All three are what E2's per-process side table exists to fix; until then arena mode is - * for state a worker family reads by property access, and frozen semantics still apply - - * mutations are rolled back at request end, exactly as in the default mode. + * ## 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 @@ -225,6 +261,11 @@ public static function bootShared( $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; @@ -288,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( @@ -304,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( @@ -311,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 @@ -485,7 +558,7 @@ public function attachObject(int $address): object $address, )); } - if (!isset($this->handles[$address])) { + if (!$this->sideTable->has($address)) { $this->rebindClassEntry($object); $this->register($address, $object->object); } @@ -493,6 +566,182 @@ public function attachObject(int $address): 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, + ); + } + + /** + * 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 */ @@ -520,6 +769,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 { @@ -528,7 +787,8 @@ public function detach(): void } // Drop our own references first so only foreign references remain in the count - $this->instances = []; + $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 @@ -537,7 +797,7 @@ public function detach(): void // those carry a class entry this process never rebound - rolling them back would // dereference another process's zend_class_entry pointer. $objects = []; - foreach (array_keys($this->handles) as $address) { + foreach ($this->sideTable->addresses() as $address) { $object = $this->registry->findObject($address); if ($object !== null) { $objects[] = $object; @@ -545,23 +805,13 @@ public function detach(): void } 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 @@ -571,11 +821,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; } @@ -607,6 +861,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) { @@ -669,7 +933,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); @@ -686,11 +950,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; } /** @@ -701,10 +986,137 @@ 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. + */ + 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); } /** @@ -753,7 +1165,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/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..70ec69b --- /dev/null +++ b/src/SharedObjectHandle.php @@ -0,0 +1,530 @@ + + * + * 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); + } + + /** + * 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/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 = []; + } +} From 4ffbfed9cf392c3739c0f66c015d7d3f54f18fbd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 21:01:13 +0000 Subject: [PATCH 25/27] fix(shm): stop unmapping the arena at request shutdown The mapping was released by a shutdown function armed in Arena::create(), and PHP destroys the symbol table, the object store and every remaining zval AFTER its shutdown functions have run. So any variable still holding a shared object - a global, a static, a store that had not detached yet - was released against memory that was no longer mapped, and the process died with SIGSEGV after a completely green test run: the report is printed long before the crash, so the suite says OK and the exit code says 139. No ordering inside the class can fix it. The mapping is necessarily created before anything that points into it, and shutdown functions run in registration order, so the unmap can never be last. It also does not need to exist: the arena is process-scoped by design and the kernel reclaims the mapping when the process exits, which is precisely the lifetime the leak-until-teardown model already assumes. destroy() stays for a caller who genuinely owns the moment, with the requirement spelled out. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- README.md | 4 +++- docs/shared-memory-model.md | 10 +++++++--- src/Shm/Arena.php | 38 +++++++++++++++++++++++++------------ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3a41c47..a3ab40c 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,9 @@ tables refuse the insert with a typed `ArenaException` instead. Sizes come from 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 only the creating process unmaps the region, at shutdown. `watermark()` +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 diff --git a/docs/shared-memory-model.md b/docs/shared-memory-model.md index a4a41b9..b2544aa 100644 --- a/docs/shared-memory-model.md +++ b/docs/shared-memory-model.md @@ -144,9 +144,13 @@ failure instead of overlapping neighbours. 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 (a child unmapping the region would -pull memory out from under its parent and siblings, so `destroy()` in a child is a deliberate -no-op). +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: diff --git a/src/Shm/Arena.php b/src/Shm/Arena.php index 20aafd1..1928712 100644 --- a/src/Shm/Arena.php +++ b/src/Shm/Arena.php @@ -51,8 +51,10 @@ * * `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 (only it unmaps - a child unmapping the region - * would pull memory out from under its parent and its siblings). This is deliberate for v1: + * 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(). * @@ -213,12 +215,8 @@ public static function create(?int $size = null): self Libc::initSharedMutex($arena->mutexAt($index)); } - // Only the creator ever unmaps - children inherit this shutdown function through - // fork() and it has to stay a no-op there - register_shutdown_function(static function () use ($arena): void { - $arena->destroy(); - }); - + // 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; } @@ -675,10 +673,26 @@ public function writeBytes(int $address, string $bytes): int /** * Unmaps the arena - the creating process only, and only once * - * A child calling this is a deliberate no-op rather than an error: the shutdown - * function armed at create() is inherited by every fork, and a child unmapping the - * region would tear the arena out from under its parent and siblings. A child's own - * copy of the mapping goes away with the process anyway. + * 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 { From 11d8d8b91fffb7ddddeb87b12439e4e454ce2610 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 21:12:05 +0000 Subject: [PATCH 26/27] test(tests): promote the mutation, side-table and lifecycle claims to real processes The cases the design rests on, run by actual forked workers on every change instead of quoted from a log: two children writing and reading one object under its stripe lock over 100k rounds, with a two-slot invariant that a half-applied update would break and an unlocked string reader that would see a torn pointer if an aligned 8-byte swap could tear; a child writing a string and a reference that every other process then follows; two overlapping children proving handles are per-process while the shared field is not an identity; a child making the engine cache a property bag inside a shared object and leaving nothing behind for its sibling to dereference. Then the lifecycle: shared state is not rolled back while a frozen graph in the same arena still is, a direct write is visible but its heap string is repaired rather than shared, a child is refused when it tries to free arena memory, and - the regression for the teardown crash - a worker that keeps a shared object alive and simply exits does so with status 0 rather than a signal. The single-process file covers what may be written at all: the refusals, the declared-type enforcement the engine never gets to do, the disabled alias predicate in shared mode against the frozen one that still refuses, and the pin that lifting the frozen semantics does not lift. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- src/PersistentStore.php | 21 + src/SharedObjectHandle.php | 91 +++++ tests/Shm/MutableSharedForkTest.php | 593 ++++++++++++++++++++++++++++ tests/Shm/SharedMutationTest.php | 346 ++++++++++++++++ tests/Stub/MutableCounter.php | 47 +++ 5 files changed, 1098 insertions(+) create mode 100644 tests/Shm/MutableSharedForkTest.php create mode 100644 tests/Shm/SharedMutationTest.php create mode 100644 tests/Stub/MutableCounter.php diff --git a/src/PersistentStore.php b/src/PersistentStore.php index dcd7fec..1e1f23b 100644 --- a/src/PersistentStore.php +++ b/src/PersistentStore.php @@ -632,6 +632,18 @@ public function mutableHandle(object|int $target): SharedObjectHandle ); } + /** + * 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 */ @@ -1007,6 +1019,15 @@ private function unregister(int $address): void * 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 { diff --git a/src/SharedObjectHandle.php b/src/SharedObjectHandle.php index 70ec69b..b25b448 100644 --- a/src/SharedObjectHandle.php +++ b/src/SharedObjectHandle.php @@ -261,6 +261,97 @@ public function writeScalar(string $property, int|float|bool|null $value): void $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 * 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]; +} From e4358d057074089e0a542f2a3f8ce88922dc8d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 21:15:06 +0000 Subject: [PATCH 27/27] docs: document shared mutation and the per-process fields as shipped behaviour The README still described the per-process fields and frozen-only semantics as limits of the next iteration, which they no longer are. Replaces that list with what the package actually does now - identity through the arena address, the property cache scrubbed rather than avoided, and the opt-in mutation API with the two rules a caller has to know: what the write path refuses, and what a direct property write really does. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B9xaBchjdo1atarNZ6sZqe --- README.md | 55 +++++++++++++++++++++++++++++-------- docs/shared-memory-model.md | 3 +- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a3ab40c..c69c9dc 100644 --- a/README.md +++ b/README.md @@ -249,17 +249,50 @@ crash. Cross-process locking uses a bank of 64 `PTHREAD_PROCESS_SHARED | PTHREAD mutexes inside the arena, so a SIGKILLed worker hands the lock on (`EOWNERDEAD`) instead of wedging the pool. -**Known limits of this first iteration** (all of them are what the per-process side table -of the next iteration fixes, and all of them are spelled out on `bootShared()`): - -- classes must be loaded **before the fork** — a shared object carries one class-entry - pointer for the whole family; -- `spl_object_id()` is not meaningful on a shared object, and forked children even receive - identical object-store handles — the registry keys everything by arena address instead; -- `get_object_vars()`, `var_dump()`, `json_encode()` and `(array)` casts make engine C code - cache a request-heap pointer inside the shared object; avoid them on shared instances; -- frozen semantics still apply: request-time mutations are rolled back at request end. - Shared **mutable** state is the next ticket. +**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 diff --git a/docs/shared-memory-model.md b/docs/shared-memory-model.md index b2544aa..89ced82 100644 --- a/docs/shared-memory-model.md +++ b/docs/shared-memory-model.md @@ -284,7 +284,8 @@ is the Never-Serialize Rule in one sentence. | 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; the synchronized path is the explicit write API | +| 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) |