diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 810810ca..b446f79b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,9 +35,9 @@ jobs: opcache_gate: composer test:opcache - ts: zts ts_label: ZTS - # The file-cache relocator does not support ZTS payloads yet - # (issue #118): its tests self-skip on ZTS, so the non-skip gate - # excludes that group while still proving the SHM tests ran + # Since issue #118 the file-cache relocator supports ZTS payloads, + # so this gate covers the full opcache group (the script is the + # named alias the ZTS legs call; it no longer excludes anything) opcache_gate: composer test:opcache-zts steps: - uses: actions/checkout@v7 @@ -127,6 +127,14 @@ jobs: tests-macos: name: Tests (macOS ${{ matrix.runner-arch.arch }}, ${{ matrix.ts }}) runs-on: ${{ matrix.runner-arch.runner }} + # setup-php installs PHP through Homebrew on macOS, and by default brew runs + # a full `brew update` before every install and a cleanup after it - tens of + # seconds of unrelated formula churn on top of the PHP install this job + # needs. Both are pure overhead here (the runner image already ships a recent + # brew and nothing else is installed), so switch them off for the whole job. + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' strategy: fail-fast: false matrix: @@ -195,8 +203,8 @@ jobs: run: composer test # Same gates as the Linux legs (issue #124): the opcache/SHM tests must - # have RUN. As on Linux, the ZTS leg excludes the file-cache relocator - # group (no ZTS payload support yet, issue #118). + # have RUN. Since issue #118 the relocator group runs on ZTS too, so + # both legs cover the full opcache group. - name: Opcache/SHM coverage must not silently skip if: steps.artifacts.outputs.present == 'true' run: ${{ matrix.ts == 'zts' && 'composer test:opcache-zts' || 'composer test:opcache' }} @@ -376,12 +384,12 @@ jobs: include: - ts: nts ts_label: NTS - # ZTS excludes the relocator tests (no ZTS payload support yet, - # issue #118) but still gates the SHM coverage against silent skips opcache_args: --group opcache --fail-on-skipped - ts: zts ts_label: ZTS - opcache_args: --group opcache --exclude-group opcache-relocator --fail-on-skipped + # Since issue #118 the relocator tests run on ZTS too - both legs + # gate the full opcache group against silent skips + opcache_args: --group opcache --fail-on-skipped steps: - uses: actions/checkout@v7 @@ -603,6 +611,11 @@ jobs: header-drift-darwin: name: Generated darwin headers up to date (${{ matrix.runner-arch.arch }}, ${{ matrix.ts }}) runs-on: ${{ matrix.runner-arch.runner }} + # See tests-macos: skip brew's auto-update/cleanup so setup-php only pays for + # the PHP install it actually needs. + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' strategy: fail-fast: false matrix: diff --git a/.github/workflows/generate-darwin-headers.yml b/.github/workflows/generate-darwin-headers.yml index 75f7bd49..54840c92 100644 --- a/.github/workflows/generate-darwin-headers.yml +++ b/.github/workflows/generate-darwin-headers.yml @@ -40,6 +40,12 @@ env: jobs: generate: name: Generate darwin-${{ matrix.runner-arch.arch }}-${{ matrix.ts }} + # setup-php installs PHP through Homebrew on macOS; brew's default + # pre-install update and post-install cleanup are tens of seconds of + # unrelated churn this generate step does not need. Switch them off. + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' strategy: fail-fast: false matrix: diff --git a/AGENTS.md b/AGENTS.md index a1a3691e..341aefb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,8 +131,8 @@ PRs), or manually via `workflow_dispatch` against any branch. Darwin covers **NTS and ZTS**: the workflow's matrix crosses both architectures with both thread-safety modes (setup-php builds the ZTS PHP via `phpts: ts`). As on Linux, the ZTS artifacts reach EG/CG through the TSRM -offsets, and the opcache file-cache relocator stays unsupported on ZTS -(issue #118). After changing the generator on this branch, remember the 8.5 +offsets; the opcache file-cache relocator runs on ZTS since issue #118. +After changing the generator on this branch, remember the 8.5 line on `master` needs its own `workflow_dispatch` run to refresh its darwin artifacts. @@ -192,10 +192,12 @@ composer test:internal # destructive/segfault-prone group, process-isolated - `ZENGINE_STRICT_LAYOUT_CHECK=1` (set in the test bootstrap) makes `Core::init()` verify every struct layout against `layouts.json` before touching engine memory — the anti-segfault airbag. Keep it on in development. -- On **ZTS** builds the file-cache relocator tests (`opcache-relocator` group) - self-skip — ZTS payloads are not supported yet (issue #118). The non-skip - gate for the remaining opcache/SHM coverage is `composer test:opcache-zts`; - CI runs both release and debug test legs on NTS **and** ZTS. +- The file-cache relocator supports **ZTS** payloads since issue #118 (the + binary layout is thread-safety-agnostic: zend_file_cache.c has no ZTS + conditionals and every walked struct is layout-identical across the modes). + `composer test:opcache-zts` stays as the named alias CI's ZTS legs call; it + now runs the full opcache group, relocator tests included. CI runs both + release and debug test legs on NTS **and** ZTS. - `composer test:opcache-runner` runs the suite the way an opcache-enabled consumer does — `opcache.enable_cli=1` in the **runner process itself**, so every test file is compiled into shared memory (CI has a dedicated Linux job diff --git a/composer.json b/composer.json index f19d428b..b161e14c 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "test": "phpunit", "test:internal": "phpunit --group internal --process-isolation", "test:opcache": "phpunit --group opcache --fail-on-skipped", - "test:opcache-zts": "phpunit --group opcache --exclude-group opcache-relocator --fail-on-skipped", + "test:opcache-zts": "phpunit --group opcache --fail-on-skipped", "test:opcache-runner": "phpunit --exclude-group performance --exclude-group internal --exclude-group opcache-incompatible", "test:performance": "phpunit --group performance --fail-on-skipped", "phpstan": "phpstan analyse", @@ -53,7 +53,7 @@ "test": "Run the test suite (segfault-prone internal group excluded)", "test:internal": "Run the segfault-prone internal test group with process isolation (use a debug PHP build)", "test:opcache": "Run the opcache/shared-memory tests and FAIL if any of them skipped (they are self-skipping when opcache is unavailable)", - "test:opcache-zts": "Same non-skip gate for ZTS builds: excludes the file-cache relocator tests, which do not support ZTS payloads yet (issue #118)", + "test:opcache-zts": "Same non-skip gate on ZTS builds - kept as the named alias CI's ZTS legs call; since issue #118 the file-cache relocator tests run there too", "test:opcache-runner": "Run the suite for an opcache-ACTIVE runner (opcache.enable_cli=1): excludes the usual performance/internal groups plus opcache-incompatible, the issue-linked tests that cannot hold when the runner's own files live in shared memory", "test:performance": "Run the excluded performance group (the 'zero FFI at call time' benchmark) and FAIL if it silently skipped - its verdict is timing-based, so the CI leg that runs it is informational", "phpstan": "Run static analysis at the maximum level", diff --git a/docs/class-specialization.md b/docs/class-specialization.md index 490d6197..42105864 100644 --- a/docs/class-specialization.md +++ b/docs/class-specialization.md @@ -115,34 +115,50 @@ two cases, and both are rejected rather than silently unenforced: ### Un-sharing the opcode array -When a plain `ZEND_RECV` has to be patched, the method's opcodes are copied into request memory -first, because they are shared with the template by design. Three operand encodings matter: +When a plain `ZEND_RECV` has to be patched, the method's opcodes **and literals** are copied +into request memory first, because they are shared with the template by design. The copy +reproduces the engine's own `pass_two()` layout in one block - opcodes at the start, the +literal zvals at the same 16-aligned offset right behind them. Three operand encodings matter: | Operand | Encoding | Survives the copy? | |---|---|---| | jump targets | *signed* byte offset from the opline itself | yes - the whole array moves as a unit, so relative distances are unchanged | -| `IS_CONST` operands | byte offset **from the opline itself** | no - every one is rebased by the distance the array moved | +| `IS_CONST` operands | byte offset **from the opline itself** | no - every one is rebased onto the copied literal at the same index | | `live_range`, `try_catch_array` | opline indices | yes | -Literals sit immediately after the opcodes in one compiler-arena block, which is why a constant -operand is opline-relative and why moving the array alone would silently make every literal -reference point at the wrong zval. Under `zend.assertions=1` the copy is verified: every -`IS_CONST` operand must resolve to the same address it resolved to before the move, and every -jump offset must still land inside the array. - -An `IS_CONST` operand stores a **signed 32-bit** offset, so the relocated opcodes have to stay -within 2GB of the literals they point at. Request memory and the compiler arena are neighbours, -so this holds for an ordinary class - but an **opcache-shared body** lives in an mmap'd region -that can be arbitrarily far from the request heap, and a truncated offset would read whatever sat -at the wrapped address. That case is detected and rejected: substituting a *builtin parameter* -type needs a body that is not in shared memory. Class-like parameters, all return types and all -properties are unaffected, because none of them un-shares the opcodes. - -Ownership mirrors the duplicated `arg_info` blocks - `destroy_op_array()` frees whichever -`opcodes` pointer its holder carries once the shared body refcount reaches zero, so one sibling -block is released through the engine and the other is reclaimed by the request allocator at -request end. Bounded at one block per patched method, and only methods that actually need a -patch pay it. An opcache-shared source is safe because it is only ever read. +A constant operand is opline-relative because opcodes and literals normally share one +compiler-arena block, and it stores a **signed 32-bit** offset - which is exactly why the +literals travel with the opcodes. The source literals can be arbitrarily far from the +relocated opcodes (an **opcache-shared body** lives in an mmap'd region well over 2GB from +the request heap, where a truncated offset would read whatever sat at the wrapped address), +but with both halves copied into one block every rebased offset is bounded by the block size +and always fits. Opcache-shared bodies are therefore fully supported. Under +`zend.assertions=1` the copy is verified: every `IS_CONST` operand must resolve to the copied +literal at the very index its source operand resolved to (landing zval-aligned inside the +copied table), and every jump offset must still land inside the array. + +The literal zvals are copied **shallowly**: both blocks reference the same payloads (strings, +arrays, ASTs), matching how the engine treats the two of them as one shared body - releases +happen only when the shared body refcount reaches zero, so exactly one dtor pass ever runs +over exactly one of the sibling zval arrays. An opcache-shared source never reaches that pass +at all: its body refcount pointer is NULL, `destroy_op_array()` returns before touching +literals, and the immortal shared-memory payloads (interned strings, immutable arrays) are +never refcounted. + +One more thing rides on the un-shared copy: opline **handlers**. Opcache's optimizer assigns +a `mixed` parameter's RECV (cached mask exactly `MAY_BE_ANY`) the `RECV_NOTYPE` handler +variant, which never reads the cached mask - so after writing the new mask, the specializer +also rebinds the patched opline to the engine's generic, mask-checking handler (taken from a +donor opline that can never be NOTYPE-specialized), exactly what the compiler assigns when a +builtin parameter type is written in source. + +Ownership mirrors the duplicated `arg_info` blocks - with relative `IS_CONST` addressing the +engine frees literals and opcodes as ONE allocation through the `opcodes` pointer (it never +`efree()`s `literals` separately once `ZEND_ACC_DONE_PASS_TWO` is set, which this block layout +is built for), so one sibling block is released through the engine once the shared body +refcount reaches zero and the other is reclaimed by the request allocator at request end. +Bounded at one block per patched method, and only methods that actually need a patch pay it. +An opcache-shared source is safe because it is only ever read. ### Rejections diff --git a/docs/hot-swap.md b/docs/hot-swap.md index afedaec4..23d51e94 100644 --- a/docs/hot-swap.md +++ b/docs/hot-swap.md @@ -124,11 +124,11 @@ covered in [opcache-binary.md](opcache-binary.md). | Target | Behaviour | |--------|-----------| -| Immutable **global function** + `redefine()` | Supported via copy-out: the per-process function-table bucket is repointed at a writable `zend_function` copy; the SHM original stays untouched and allocated. The first swap does not destroy the previous (SHM) body; later swaps behave normally. | +| Immutable **global function** + `redefine()` | Supported via copy-out: the per-process function-table bucket is repointed at a writable `zend_function` copy; the SHM original stays untouched and allocated. The first swap does not destroy the previous (SHM) body; later swaps behave normally. Only *name resolution* is redirected - call sites that already resolved the function, and call sites the optimizer inlined at cache time, keep the original body (see the copy-out caveats). | | Immutable **class**: method `redefine()`, `addMethod()`, `removeMethods()`, trait configuration, `HotSwap::prepare()` | Supported via class copy-out: the class entry is deep-copied into request memory with the [class-specialization](class-specialization.md) copy model (own tables and property/constant blocks, method entries duplicated at the `zend_op_array` level with the compiled bodies still shared with SHM), the class-table bucket and the engine's fast class-name cache are repointed at the copy, and the mutation is applied to it. The copy is an ordinary userland class the engine dismantles at request end. | | Immutable **preloaded** class (`ZEND_ACC_PRELOADED`) | Rejected with `SharedMemoryException`: a preloaded class keeps its class-table bucket across the requests of a worker, while the copy lives in request memory - repointing the bucket would leave it dangling for the next request. | | Immutable class the copy machinery does not support (enum/interface/trait, property hooks, internal ancestor or internal methods) | Rejected with `SharedMemoryException` carrying the refusal reason. | -| Class observed **mid-linking** on an opcache lazy-linking temporary (`ZEND_ACC_CACHED` set, `ZEND_ACC_LINKED` clear - the only state an `interface_gets_implemented` hook ever sees for a cached implementor) | Handler installation (`setXxxHandler()`, `installExtensionHandlers()`) rejected with `SharedMemoryException`: handlers are keyed by class-entry address and the temporary is discarded when opcache's inheritance cache persists the linked class, so they would be silently lost ([#238](https://github.com/lisachenko/z-engine/issues/238)). Probe with `ReflectionClass::isLazyLinkingCopy()`; install after linking completes, or run the bootstrap with opcache off. [#241](https://github.com/lisachenko/z-engine/issues/241) tracks making the installation stick by declining the inheritance cache for hooked classes. | +| Class observed **mid-linking** on an opcache lazy-linking temporary (`ZEND_ACC_CACHED` set, `ZEND_ACC_LINKED` clear - the only state an `interface_gets_implemented` hook ever sees for a cached implementor) | Supported via **inheritance-cache decline** ([#241](https://github.com/lisachenko/z-engine/issues/241)): handlers are keyed by class-entry address, and without intervention the temporary would be discarded as soon as opcache's inheritance cache persists the linked class, silently losing them ([#238](https://github.com/lisachenko/z-engine/issues/238)). So handler installation (`setXxxHandler()`, `installExtensionHandlers()`) records the entry, and z-engine's interceptor over `zend_inheritance_cache_add` answers NULL for it when linking completes - the engine's ordinary "not cached" outcome (opcache itself returns it when SHM is full). The temporary then stays in the class table as a process-local, request-lifetime class: the handlers keep firing, and the class simply pays re-linking per process/request instead of being reused from the cache. Unhooked classes delegate to opcache unchanged and keep full cache reuse. FPM-safety: because the hooked class is never published, no per-process trampoline or handlers-block address ever reaches shared memory (publishing the handlers through the class entry instead was rejected for exactly that hazard). Probe with `ReflectionClass::isLazyLinkingCopy()`. Fallback: on a platform whose generated engine definitions predate the `zend_inheritance_cache_add` export, the installation still throws `SharedMemoryException` instead of being silently lost - regenerate with `composer gen-headers`. | | Runtime-declared functions/classes (never in SHM, even with opcache enabled) | Full mutation surface. | | Static variables of an immutable function | Readable: `getStaticVariables()` follows the map-ptr offset slot opcache stores into shared op_arrays and returns the live per-process table once the first call materialized it (the declaration defaults before that). | @@ -136,11 +136,19 @@ covered in [opcache-binary.md](opcache-binary.md). `ReflectionException`, so existing catch blocks keep working while the failure modes stay distinguishable. +The file-cache bridge `CacheImageSync` (see +[opcache-binary.md](opcache-binary.md)) drives this same machinery from a +patched cache image instead of a closure/source donor: changed image bodies +are swapped into the already-loaded entries through `FunctionBodySwap`, with +opcache-shared targets copied out of SHM by the exact paths above — every +row of this matrix, including the refusals, applies to it unchanged. + ### Copy-out caveats -A copy-out changes which `zend_class_entry` the class name resolves to, and -only *resolution* is redirected - structures that captured the shared entry -earlier keep it. Copy out (or mutate) at bootstrap, before such state exists: +A copy-out changes which structure (`zend_class_entry`, `zend_function`) the +*name* resolves to, and only resolution is redirected - structures that +captured the shared entry earlier keep it. Copy out (or mutate) at bootstrap, +before such state exists: - **Instances created before the copy-out** keep the shared class entry in `obj->ce`: they dispatch the old method bodies and are not `instanceof` the @@ -155,6 +163,25 @@ earlier keep it. Copy out (or mutate) at bootstrap, before such state exists: call site spelling the class the way it was declared uses; a call site that already resolved the class through another spelling (`new foo\bar()` for `Foo\Bar`) in this request keeps the shared entry. +- **Call sites that already resolved the function**: the engine memoizes the + resolved `zend_function*` in the caller's run-time cache the first time a + call site executes. A caller that already called the function in this + request keeps dispatching the shared-memory entry after a function copy-out + (the function-side twin of the "instances created before the copy-out" + rule). Redefine at bootstrap, before the call sites warm up. +- **Optimizer-inlined call sites** + ([#242](https://github.com/lisachenko/z-engine/issues/242)): when opcache + caches a script, its optimizer *inlines* a same-file call to a function + whose body merely returns a literal - the call site is replaced by the + constant (`zend_try_inline_call`, optimizer pass 4, part of the default + `opcache.optimization_level`), and method calls can be folded the same way + when the optimizer proves the receiver's class. Such call sites do not exist + in the compiled code at all, so no `redefine()` - before or after they run - + can ever affect them; only truly dynamic calls (`$name()`, a runtime + callable) always resolve at runtime. Give a redefine target a body the + optimizer cannot pre-evaluate (for example, return a runtime-defined + constant), declare it in a different file than its callers, or mask the pass + out (`opcache.optimization_level=0x7FFEBFF7`). - **Per-request only**: the copy dies with the request, and the next request of the same worker starts from the shared-memory class again - apply the mutation on every request (bootstrap), exactly like for a runtime-declared @@ -164,6 +191,9 @@ earlier keep it. Copy out (or mutate) at bootstrap, before such state exists: - `redefine()` and `ClassDelta` **body swaps are memory-flat**: each swap releases the previous body, its heap run-time cache and its static tables. + This holds for donors declared in opcache-cached files too: their compiled + arrays live in shared memory and are never freed, but the per-entry heap + run-time cache and statics duplicate minted by the swap are released. - Each `HotSwap::prepare()` costs one class compilation. The engine allocates the op_array/class-entry **containers** from the request arena, which is only reclaimed at request end (~1 KiB per prepare for a small class, the @@ -176,8 +206,11 @@ earlier keep it. Copy out (or mutate) at bootstrap, before such state exists: ## Interactions to be aware of - **Run-time cache invalidation**: swapped entries always get a fresh cache; - caches of *other* functions that call the swapped one are untouched but safe, - because the entry pointer (what those caches store) is preserved. + caches of *other* functions that call the swapped one are untouched but safe + for in-place swaps, because the entry pointer (what those caches store) is + preserved. The shared-memory copy-out path publishes a *new* pointer instead, + so callers that already resolved the old one keep it - see the copy-out + caveats above. - **`Closure::fromCallable()` over a later-swapped method**: fake closures share the old body and keep it alive through its refcount; they continue to execute the old body (and its static variables) until released. diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index 5bc9c2b9..6c65be83 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -81,38 +81,238 @@ string literal, a new constant value — are written correctly, not just in-plac byte pokes. `refresh()` is `save()` plus `opcache_invalidate()` on the source script, so the next include picks up the patched binary. +## Growing the graph: added functions and methods + +In-place edits go out through `PayloadRelocator::derelocate()` — the exact +inverse of the read-time relocation. Mutations that outgrow the original +buffer take a different writer +([#117](https://github.com/lisachenko/z-engine/issues/117)): +`ScriptSerializer`, a two-pass port of `zend_persist_calc` → `zend_persist` +(pass 1 walks the graph, deduplicating every reachable allocation unit +through an xlat table and summing aligned sizes; pass 2 emits a fresh +contiguous region and rewrites every pointer), which then delegates the +on-disk offset encoding to the same `PayloadRelocator` serialize stage — one +implementation for the offset format. `save()` picks the writer +automatically: it re-emits from scratch once the reflection view reports the +graph as grown, and keeps the byte-exact derelocate path otherwise. + +New code enters the image as **grafts from donor binaries**: + +```php +$file = BinaryCacheFile::read($binPath, $scriptPath); +$donor = BinaryCacheFile::compile($donorScript, $donorCacheDir); + +$view = $file->getReflection(); +$view->addFunctionFrom($donor->getReflection(), 'my_new_function'); +$view->addMethodFrom($donor->getReflection(), 'DonorClass', 'newMethod', 'CachedClass'); +$file->save(); // a fresh worker now executes the added function and method +``` + +Donors are compiled by a real opcache child, so their op_arrays are already +in file form (opline handlers are handler-table indexes, IS_CONST operands +are literal-table indexes — neither is derivable in-process without engine +helpers that are not exported); the serializer copies those units verbatim. +Grafting regrows the target hashtable outside the buffer — persisted tables +must never be touched by `zend_hash_add`, their data block is not an +emalloc'd allocation — and the donor image stays referenced (and, for +methods, mutated: the op_array's scope is re-pointed at the adopting class) +until `save()` re-emits everything into one fresh region. + ## Refresh and shared memory Under `opcache.file_cache_only=1` there is no shared-memory copy, so writing the -binary is enough for the next worker to load it. When opcache also uses shared -memory, a script already resident in SHM is **not** re-read until it is -invalidated — which is exactly what `refresh()` does. Loading a patched binary -directly into shared memory (and wiring it to the function/method hot-swap API) -is future work; see [hot-swap.md](hot-swap.md). +binary is enough for the next worker to load it (`opcache_invalidate()` is a +no-op in that mode). When opcache also uses shared memory, a script already +resident in SHM is **not** re-read until it is invalidated — which is exactly +what `refresh()` does. + +Two shared-memory subtleties `refresh()` accounts for: + +- **Invalidate before write.** In a process running SHM *with* + `opcache.file_cache`, `opcache_invalidate()` also unlinks the script's cache + binary (`zend_file_cache_invalidate`). `refresh()` therefore invalidates + first and writes second, so the unlink hits the stale binary — the worst + case if the write then fails is a cache miss and a recompile of the original + source, never a silently lost patch. +- **Same-process pickup needs `opcache.revalidate_path=1`.** After an + in-process invalidation, opcache's default key lookup finds the invalidated + hash entry without resolving the script path and never consults the file + cache again, so a re-include in the *same* process recompiles the source. + With `opcache.revalidate_path=1` the path is resolved, the patched binary is + loaded from the file cache back into shared memory, and the re-include + executes the patched body. A **fresh** worker (an empty SHM — e.g. a pool + worker after restart) picks the patched binary up with default settings. + +Publishing a patched binary directly into shared memory (bypassing the file +cache) is **not planned** — the write-path opcache symbols are hidden from FFI +and the segment is protected against out-of-band writes +([#121](https://github.com/lisachenko/z-engine/issues/121), closed with the +feasibility analysis). `refresh()`'s file-cache→SHM reload is the supported SHM +publication mechanism. Applying a patched image to code **already loaded in the +current process** is a different loop, closed by `CacheImageSync` — see the next +section and [hot-swap.md](hot-swap.md). + +## Applying a patched image to the live process (`CacheImageSync`) + +`refresh()` only affects the *next* include. `ZEngine\HotSwap\CacheImageSync` +closes the other half of the loop (issue #122): it diffs a (patched) image +against the functions and classes **already loaded** in this process and swaps +the changed compiled bodies in place, through the same runtime machinery +`redefine()`/`ClassDelta` use — no re-include, warmed-up call sites keep +dispatching the same entry pointers. + +```php +$image = $file->getReflection(); +// ... patch literals/opcodes through the wrappers ... +$sync = CacheImageSync::prepare($image); // read-only diff +$sync->getChangedFunctions(); // introspect the plan +$report = $sync->apply(); // swap the changed bodies, loudly +$report->appliedMethods; // what actually happened, per entry +``` + +- **Diff basis.** `prepare()` compares each image body with its live + counterpart: body metrics (opcode/literal/CV/temporary/argument counts), + fn_flags without the storage-only bits, CV names, every opline in + canonicalized form (IS_CONST operands by literal index — the image stores + the serialized index form, the live side the runtime offset form — with + handlers and the garbage `op1.num` of implicit-`$this` receivers ignored), + every literal and static-variable default by value. The comparison is + conservative where value equality cannot be proven: array and + constant-expression literals always count as changed (a safe re-apply, like + `ReflectionMethod::equals()`); declaration-surface-only edits (arg_info + types/names, doc comments) are not part of the basis and do not trigger a + swap on their own. +- **Execution normalization.** Donor bodies are materialized per entry + (`ImageFunctionDonor`): opcodes + literals are copied into one co-allocated + process block, IS_CONST operands are rewritten to the runtime form and the + handlers restored with the engine's own `zend_deserialize_opcode_handler()`. + The image buffer itself is never written, so `save()`/`refresh()` keep + producing valid binaries after an apply. +- **Ordering and atomicity.** `apply()` validates refusals first (nothing is + touched if the plan contains one), then copies every opcache-shared target + out of SHM, then stages all swaps — functions before classes, alphabetically + within each group — and commits only when every swap staged; a failure rolls + all staged bodies back (completed copy-outs stay, they are + behavior-preserving). +- **Scope.** Bodies of named global functions and of methods the live class + itself declares. Image-only entries (script never included here, methods or + functions only the patch added) are *reported* as not loaded — the next + include picks them up. The script's main op_array, class constants, property + defaults and attributes are out of scope. +- **Refusals (throw-or-work, never silent).** Changed methods of an + enum/interface/trait throw `HotSwapException::unsupportedKind`; an image + entry colliding with an internal function/class throws; opcache-shared + targets follow the [hot-swap.md](hot-swap.md) copy-out matrix, so preloaded + classes and copy-unsupported shapes (property hooks, internal ancestors) + throw `SharedMemoryException`. Unchanged entries of a refused kind are not + operations and pass. +- **Lifetime.** Swapped-in bodies execute out of the materialized blocks and + the relocated image buffer: the sync retains both (and the view retains the + buffer), all are request-lifetime allocations the engine provably never + frees through table teardown (the bodies carry no refcount, exactly like + shared-memory bodies). Apply per request, like every other runtime mutation. +- **Apply-target seam.** `prepare()` is application-agnostic: the prepared + diff (`getChangedFunctions()`/`getChangedMethods()` plus the image handle) is + independent of where the swapped bodies land. Today `apply()` writes the + per-process tables; a different consumer could reuse the same diff against + another target. Direct SHM publication is not one of those targets + ([#121](https://github.com/lisachenko/z-engine/issues/121) — infeasible vs + stock opcache); the file-cache→SHM reload of `refresh()` covers that need. ## Scope and limits (v1) -- **Platform.** The relocator targets the bundled 64-bit non-Windows build; it - asserts `PHP_INT_SIZE === 8` and a `/` path separator and throws - `OpCacheException::unsupportedPayload` otherwise. **Windows opcache support +- **Platform.** The relocator targets 64-bit POSIX builds - linux and macOS + (x64 and arm64) alike; it asserts `PHP_INT_SIZE === 8` and a `/` path + separator and throws `OpCacheException::unsupportedPayload` otherwise. + Darwin needs no per-opline walking of its own + ([#119](https://github.com/lisachenko/z-engine/issues/119)): the + absolute-address opline branches of zend_file_cache.c + (`ZEND_USE_ABS_CONST_ADDR`/`ZEND_USE_ABS_JMP_ADDR`) are compiled in only + when `SIZEOF_SIZE_T == 4` (zend_compile.h), so every 64-bit build - darwin + included - stores IS_CONST operands as literal-table indexes and jumps as + opline-relative byte offsets, both position-independent and preserved + verbatim. `OpcodeAddressingModelTest` proves that on a real payload and + fails loudly if a build ever diverges; the 32-bit builds that do use + absolute addressing are refused by the `PHP_INT_SIZE` predicate. + **Windows opcache support is an intentional non-goal**, not pending work: the relocator (and `opcache.preload`-based features) keep rejecting Windows loudly, and the Windows half of the original platform ticket was retired when [#119](https://github.com/lisachenko/z-engine/issues/119) was rescoped to - macOS/arm64. ZTS payloads stay tracked in - [#118](https://github.com/lisachenko/z-engine/issues/118). -- **Strict, never silent.** Structures the port does not yet handle - (intersection/union type lists, property hooks, iterator/ArrayAccess funcs, - trait-using classes, compile warnings) raise `unsupportedPayload` rather than - writing a subtly corrupt binary. Global functions, classes with constants, - typed properties, attributes (including constant-expression arguments), static - variables, try/catch and enums are supported and round-trip byte-for-byte. -- **Deferred.** Loading patched binaries into shared memory (ZCSG), and applying - a patched image to already-loaded classes via `redefine()` / `ClassDelta`. + macOS/arm64. ZTS payloads are supported since + [#118](https://github.com/lisachenko/z-engine/issues/118): the file-cache + binary layout is thread-safety-agnostic (zend_file_cache.c has no ZTS + conditionals, and every struct the walker dereferences is layout-identical + across the modes — only EG/CG/module_entry differ, none of which appear in + a payload). +- **Strict, never silent.** Anything the port cannot handle raises + `unsupportedPayload` rather than writing a subtly corrupt binary; with every + payload shape of the 8.4 walker now ported, that guard covers the platform + predicates above (Windows/32-bit). Global functions, + classes with constants, typed properties (union/intersection/DNF type lists + included), trait-using classes (aliases and insteadof precedences included), + closures and arrow functions (nested dynamic_func_defs included), + Iterator/IteratorAggregate/ArrayAccess classes (including the linked-class + iterator_funcs_ptr / arrayaccess_funcs_ptr structs), property hooks, + attributes (including constant-expression arguments), static variables, + compile warnings, try/catch and enums are supported and round-trip + byte-for-byte. +- **Graph growth.** Added functions and methods are supported through donor + grafts and the from-scratch `ScriptSerializer` (see "Growing the graph" + above, issue #117); whole added classes and freshly in-process compiled + op_arrays (no file-form oplines) remain out of scope and are refused loudly. +- **Deferred.** Loading patched binaries into shared memory (ZCSG, + [#121](https://github.com/lisachenko/z-engine/issues/121)). Applying a + patched image to already-loaded functions and classes landed as + `CacheImageSync` (see above). + +## Trust model — the `.bin` input must be trusted + +Loading a cache binary is loading **code**. The relocator turns stored byte +offsets into real engine addresses that the interpreter then executes, so a +`.bin` file is exactly as trusted as the PHP source it was compiled from. Treat +it that way: read binaries only from a location your own deployment controls. + +Two header fields look like integrity checks but are **not** authentication: + +- **`system_id`** is a *build fingerprint* — a hash of the PHP version, + extension set and build flags. It exists so a binary compiled by one build is + refused by an incompatible one (`systemIdMismatch`), preventing accidental + ABI mismatch. It says nothing about *who* produced the binary; anyone can + compute the current build's `system_id` and stamp it on a crafted file. +- **`checksum`** is an **adler32** of the payload. It catches accidental + corruption (a truncated write, a bad disk block). adler32 is trivially + forgeable — an attacker who alters the payload simply recomputes it — so it + is not tamper protection against a motivated adversary. + +Because neither field authenticates the producer, the relocator does **not** +rely on them for safety. Instead, **every stored offset, count and element span +is bounds-validated against the declared buffer before it is dereferenced** +(issue #123): interior-pointer offsets against `[0, memSize]`, tagged +interned-string offsets against `[0, strSize)`, `scriptOffset` and every +count-driven element array (hashtable buckets, literals, arg_info, vars, type +lists, class/trait names, property hooks, `dynamic_func_defs`, warnings, early +bindings, …) against the region bounds. A violation raises +`OpCacheException::malformedPayload` — a loud refusal, never an out-of-bounds +engine read/write. The validation lives in the `relocate()` (read) path, the +untrusted-input surface; `derelocate()`/`serialize()` and the graph +`ScriptSerializer` operate on an already-relocated, in-process image and +inherit that validation. This is defense in depth, **not** a licence to load +untrusted binaries: it converts a memory-safety catastrophe into a clean +exception, but a validated binary can still contain hostile *compiled code*. + +**Distributing protected binaries.** If you need to ship binaries across a trust +boundary (a build server to production hosts, say), authenticate them yourself +with a keyed MAC or a signature over the file before loading — e.g. an +HMAC-SHA256 with a deployment secret, verified before `BinaryCacheFile::read()`. +A built-in keyed-MAC mode is a possible future option (a follow-up to issue +#123); it is deliberately not part of this version, because the right key +management belongs to the deploying application, not the library. ## Failure modes Everything the API rejects is a static factory on `OpCacheException` (`invalidMagic`, `truncatedFile`, `systemIdMismatch`, `checksumMismatch`, -`binFileNotFound`, `compilationFailed`, `unsupportedPayload`, …), so call sites -read as intent and the wording lives in one place. +`binFileNotFound`, `compilationFailed`, `unsupportedPayload`, +`malformedPayload`, …), so call sites read as intent and the wording lives in +one place. diff --git a/include/8.4/darwin-arm64-nts/engine.h b/include/8.4/darwin-arm64-nts/engine.h index 472fede6..442ab4ab 100644 --- a/include/8.4/darwin-arm64-nts/engine.h +++ b/include/8.4/darwin-arm64-nts/engine.h @@ -1009,6 +1009,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1055,4 +1056,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/darwin-arm64-zts/engine.h b/include/8.4/darwin-arm64-zts/engine.h index 767b4f7c..740605b6 100644 --- a/include/8.4/darwin-arm64-zts/engine.h +++ b/include/8.4/darwin-arm64-zts/engine.h @@ -1011,6 +1011,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1058,4 +1059,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/darwin-x64-nts/engine.h b/include/8.4/darwin-x64-nts/engine.h index b3ecca16..ae2356be 100644 --- a/include/8.4/darwin-x64-nts/engine.h +++ b/include/8.4/darwin-x64-nts/engine.h @@ -1009,6 +1009,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1055,4 +1056,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/darwin-x64-zts/engine.h b/include/8.4/darwin-x64-zts/engine.h index def77df7..5a4f8066 100644 --- a/include/8.4/darwin-x64-zts/engine.h +++ b/include/8.4/darwin-x64-zts/engine.h @@ -1011,6 +1011,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1058,4 +1059,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/linux-x64-nts/engine.h b/include/8.4/linux-x64-nts/engine.h index 5fa96e46..46c9f7f3 100644 --- a/include/8.4/linux-x64-nts/engine.h +++ b/include/8.4/linux-x64-nts/engine.h @@ -1013,6 +1013,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1059,4 +1060,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/linux-x64-zts/engine.h b/include/8.4/linux-x64-zts/engine.h index aa2e5590..4198c4a1 100644 --- a/include/8.4/linux-x64-zts/engine.h +++ b/include/8.4/linux-x64-zts/engine.h @@ -1105,6 +1105,7 @@ extern zval * zend_hash_index_find(const HashTable *, zend_ulong); extern void zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * zend_objects_new(zend_class_entry *); extern void zend_object_std_init(zend_object *, zend_class_entry *); @@ -1152,4 +1153,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/windows-x64-nts/engine.h b/include/8.4/windows-x64-nts/engine.h index 679d8733..dcd62bdf 100644 --- a/include/8.4/windows-x64-nts/engine.h +++ b/include/8.4/windows-x64-nts/engine.h @@ -1029,6 +1029,7 @@ extern zval * __vectorcall zend_hash_index_find(const HashTable *, zend_ulong); extern void __vectorcall zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void __vectorcall zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * __vectorcall zend_objects_new(zend_class_entry *); extern void __vectorcall zend_object_std_init(zend_object *, zend_class_entry *); @@ -1074,4 +1075,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/include/8.4/windows-x64-zts/engine.h b/include/8.4/windows-x64-zts/engine.h index 9cc19b64..71281700 100644 --- a/include/8.4/windows-x64-zts/engine.h +++ b/include/8.4/windows-x64-zts/engine.h @@ -1031,6 +1031,7 @@ extern zval * __vectorcall zend_hash_index_find(const HashTable *, zend_ulong); extern void __vectorcall zend_hash_destroy(HashTable *); extern zend_result zend_set_user_opcode_handler(uint8_t, user_opcode_handler_t); extern user_opcode_handler_t zend_get_user_opcode_handler(uint8_t); +extern void __vectorcall zend_deserialize_opcode_handler(zend_op *); extern void zend_do_inheritance_ex(zend_class_entry *, zend_class_entry *, _Bool); extern zend_object * __vectorcall zend_objects_new(zend_class_entry *); extern void __vectorcall zend_object_std_init(zend_object *, zend_class_entry *); @@ -1077,4 +1078,6 @@ extern zend_ast_process_t zend_ast_process; extern void (*zend_error_cb)(int, zend_string *, const uint32_t, zend_string *); extern void (*zend_throw_exception_hook)(zend_object *); extern void (*zend_interrupt_function)(zend_execute_data *); +extern zend_class_entry * (*zend_inheritance_cache_get)(zend_class_entry *, zend_class_entry *, zend_class_entry **); +extern zend_class_entry * (*zend_inheritance_cache_add)(zend_class_entry *, zend_class_entry *, zend_class_entry *, zend_class_entry **, HashTable *); extern char zend_system_id[32]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b0e4f10d..2a242d33 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -186,12 +186,24 @@ parameters: count: 1 path: src/Core.php + - + message: '#^Access to an undefined property FFI\:\:\$zend_inheritance_cache_add\.$#' + identifier: property.notFound + count: 1 + path: src/Core.php + - message: '#^Access to an undefined property FFI\:\:\$zend_system_id\.$#' identifier: property.notFound count: 1 path: src/Core.php + - + message: '#^Dead catch \- FFI\\Exception is never thrown in the try block\.$#' + identifier: catch.neverThrown + count: 1 + path: src/Core.php + - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable diff --git a/phpstan.dist.neon b/phpstan.dist.neon index a14488c3..2e68a4c6 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -68,33 +68,6 @@ parameters: - identifier: offsetAccess.nonOffsetAccessible path: src/Type/StructArray.php - # PayloadRelocator is the file-cache pointer-surgery machinery: it walks - # engine structs field by field through FFI\CData, so a chained access - # (`$zval->u1->v->type`) resolves to `mixed` after the first hop and the - # arithmetic/casts on those reads cannot be statically typed. The FFI - # blast radius is confined to this one audited file (AGENTS.md); every - # path is covered by the byte-identity and execute-from-cache tests. - - - identifier: property.nonObject - path: src/OpCache/PayloadRelocator.php - - - identifier: binaryOp.invalid - path: src/OpCache/PayloadRelocator.php - - - identifier: cast.int - path: src/OpCache/PayloadRelocator.php - - - identifier: argument.type - path: src/OpCache/PayloadRelocator.php - # ReflectionOpcacheFile is the CData facade over the relocated script: - # it reads embedded engine structs (script.filename, function/class - # tables) that resolve to `mixed` after the first CData hop. - - - identifier: property.nonObject - path: src/OpCache/ReflectionOpcacheFile.php - - - identifier: argument.type - path: src/OpCache/ReflectionOpcacheFile.php # The dimension tests exist to prove that a plain `count($object)` reaches the engine's # count_elements handler on a class that never declared the count itself. Rewriting them # as assertCount() would measure PHPUnit's Count constraint instead of the language diff --git a/src/Core.php b/src/Core.php index 42744494..f315398d 100644 --- a/src/Core.php +++ b/src/Core.php @@ -25,6 +25,7 @@ use ZEngine\System\Executor; use ZEngine\System\Hook\AstProcessHook; use ZEngine\System\Hook\ErrorCallbackHook; +use ZEngine\System\Hook\InheritanceCacheAddHook; use ZEngine\System\Hook\InterruptHook; use ZEngine\Type\HashTable; @@ -250,6 +251,27 @@ class Core */ private static array $generatedFunctions = []; + /** + * Interceptor installed over opcache's zend_inheritance_cache_add callback (issue #241) + * + * Present and installed only when the engine binding exports the symbol AND opcache + * published a callback into it; null otherwise, in which case handler installation on + * a lazy-linking temporary keeps the throw-guard fallback of issue #238. + */ + private static ?InheritanceCacheAddHook $inheritanceCacheAddHook = null; + + /** + * Addresses of temporary lazy-linking class entries whose publication into opcache's + * inheritance cache must be declined, so they stay process-local and their + * address-keyed handlers stay valid (issues #238/#241) + * + * Entries are consumed by the interceptor when linking completes and the whole set is + * dropped on shutdown(), so it never outlives the request that recorded it. + * + * @var array + */ + private static array $declinedInheritanceCachePublications = []; + /** * Whether Core::shutdown() has run: no engine pointers may be written anymore */ @@ -333,10 +355,106 @@ public static function init(): void self::preloadFrameworkClasses(); self::loadEngineConstants(); + self::installInheritanceCacheInterception(); self::$initialized = true; } + /** + * Installs the interceptor over opcache's zend_inheritance_cache_add callback (idempotent) + * + * The interceptor is what lets handler installation on a lazy-linking temporary stick + * (issue #241): a class recorded via declineInheritanceCachePublication() is answered + * with NULL - the engine's ordinary "not cached" outcome - so the temporary stays in + * the class table as a process-local class instead of being replaced by the published + * shared-memory entry. Everything else delegates to the saved opcache callback. + * + * Not installed when the engine binding predates the exported symbol (stale platform + * artifacts - canDeclineInheritanceCachePublication() then reports false and the + * ReflectionClass throw-guard of issue #238 stays in charge) or when the pointer is + * NULL (no opcache in this process - no lazy-linking temporaries can exist either, + * and installing a trampoline while zend_inheritance_cache_get stays NULL would only + * disable the engine's is_cacheable fast path bookkeeping for no benefit). + */ + private static function installInheritanceCacheInterception(): void + { + if (self::$inheritanceCacheAddHook !== null && self::$inheritanceCacheAddHook->isInstalled()) { + return; + } + self::$inheritanceCacheAddHook = null; + try { + /** @var CData|null $originalAdd Untyped read off the FFI binding boundary */ + $originalAdd = self::$engine->zend_inheritance_cache_add; + } catch (FFI\Exception) { + // Engine definitions without the symbol (regenerate with `composer gen-headers`) + return; + } + if ($originalAdd === null) { + return; + } + + $hook = new InheritanceCacheAddHook( + static fn(int $classEntryAddress): bool => self::takeInheritanceCacheDecline($classEntryAddress), + self::$engine, + ); + $hook->install(); + self::$inheritanceCacheAddHook = $hook; + } + + /** + * Checks whether inheritance-cache publication can be declined in this process + * + * True when the zend_inheritance_cache_add interceptor is installed (engine binding + * exports the symbol and opcache published a callback). When false under opcache, + * handler installation on a lazy-linking temporary falls back to the loud + * SharedMemoryException guard of issue #238. + */ + public static function canDeclineInheritanceCachePublication(): bool + { + return self::$inheritanceCacheAddHook !== null && self::$inheritanceCacheAddHook->isInstalled(); + } + + /** + * Records a class entry whose publication into opcache's inheritance cache must be + * declined when its linking completes (issue #241) + * + * Called with the address of the temporary lazy-linking copy - the entry an + * interface_gets_implemented hook observes and installs handlers on. Declined, the + * class stays process-local and mutable (re-linked per request instead of reused + * from the cache), so handlers keyed to its address survive linking and no + * process-local trampoline address ever reaches shared memory. + * + * @param int $classEntryAddress Address of the temporary zend_class_entry (Core::addressOf()) + */ + public static function declineInheritanceCachePublication(int $classEntryAddress): void + { + if (!self::canDeclineInheritanceCachePublication()) { + throw new \LogicException( + 'Inheritance-cache publication cannot be declined: the zend_inheritance_cache_add ' + . 'interceptor is not installed (probe with Core::canDeclineInheritanceCachePublication())', + ); + } + self::$declinedInheritanceCachePublications[$classEntryAddress] = true; + } + + /** + * Consumes one recorded decline for the given class entry address + * + * The entry is removed when found, so the set stays bounded: a hit means the class + * just finished linking and its cache publication is being declined right now. + * + * @internal called by InheritanceCacheAddHook::handle() from inside the engine callback + */ + public static function takeInheritanceCacheDecline(int $classEntryAddress): bool + { + if (!isset(self::$declinedInheritanceCachePublications[$classEntryAddress])) { + return false; + } + unset(self::$declinedInheritanceCachePublications[$classEntryAddress]); + + return true; + } + /** * Preloads definition and Core for ffi.preload mode, should be called during preload stage for better performance * @@ -726,6 +844,29 @@ public static function addressOf(object $pointer): int return (int) self::cast('uintptr_t', $pointer)->cdata; } + /** + * Returns the numeric address of a POINTER CData without any internal throw + * + * Exactly addressOf() for values that are already pointers, minus cast()'s + * array-decay probe, which throws and catches an FFI\Exception per call. Engine + * callbacks that can fire while CG(in_compilation) is set (the intercepted + * zend_inheritance_cache_add runs during compile-time early binding) must not + * throw AT ALL - the engine promotes every thrown exception to an immediate + * fatal error there, before any catch block runs. + * + * @param CData|object $pointer Pointer CData (never a C array); statically + * stub-typed views are accepted + * + * @internal for engine-callback hot paths (InheritanceCacheAddHook) + */ + public static function pointerAddressOf(object $pointer): int + { + assert($pointer instanceof CData); + $address = self::$engine->cast('uintptr_t', $pointer)->cdata; + + return \is_int($address) ? $address : 0; + } + /** * Materializes a typed pointer from a numeric address (the inverse of addressOf()) * @@ -994,6 +1135,12 @@ public static function shutdown(): void // lifetime by design. Dropping the registry only releases the bookkeeping. self::$trackedBlocks = []; + // The interceptor itself was uninstalled by the chain unwind above; pending decline + // records (classes whose linking never completed, e.g. after a bailout) die with + // the request that recorded them + self::$inheritanceCacheAddHook = null; + self::$declinedInheritanceCachePublications = []; + self::$isShutdown = true; } diff --git a/src/HotSwap/CacheImageSync.php b/src/HotSwap/CacheImageSync.php new file mode 100644 index 00000000..89266dbb --- /dev/null +++ b/src/HotSwap/CacheImageSync.php @@ -0,0 +1,424 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\HotSwap; + +use ZEngine\Core; +use ZEngine\OpCache\ImageFunctionDonor; +use ZEngine\OpCache\ReflectionOpcacheFile; +use ZEngine\OpCache\SharedMemoryException; +use ZEngine\Reflection\FunctionBodySwap; +use ZEngine\Reflection\PendingBodySwap; +use ZEngine\Reflection\ReflectionClass; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Reflection\ReflectionMethod; + +/** + * The bridge between the file-cache binary-patch pipeline and the runtime + * hot-swap machinery: takes a (patched) ReflectionOpcacheFile image and applies + * the changed compiled BODIES to the functions and classes ALREADY LOADED in + * this process, in place - without re-including the script. A patched binary + * alone only affects the next include (BinaryCacheFile::refresh()); this class + * closes the loop for code the process is already running. + * + * prepare() diffs the image against the live executor tables and is read-only; + * apply() drives the existing runtime machinery for every changed body: + * + * - global functions: FunctionBodySwap in-place swap, the redefine() contract + * (entry pointer, name, scope, prototype and declaration flags preserved; + * the body and its body-level flags follow the image); + * - methods: the same swap against the live class entry, ClassDelta-style - + * changed bodies only, propagating to subclasses that share the entry; + * - opcache-shared (ZEND_ACC_IMMUTABLE) live entries are first copied out of + * shared memory through the documented copy-out paths, with every refusal + * of that machinery surfacing loudly (docs/hot-swap.md support matrix). + * + * Scope: bodies of named global functions and of methods the live class itself + * declares. Entries only the image knows (script never included here, methods + * added only in the image) are REPORTED as not loaded, never invented at + * runtime; the script's main op_array and class-level data (constants, + * property defaults, attributes) are out of scope - see docs/opcache-binary.md. + * + * apply() is ordered (functions before classes, alphabetically within each + * group), staged (every swap can roll back until all succeeded) and loud: it + * either works or throws, returning a CacheImageSyncReport of what happened. + * Re-applying the same sync is refused; re-preparing against the same image + * diffs as all-unchanged, so the bridge is idempotent per image state. + * + * Seam for issue #121 (publishing a patched image into opcache shared memory): + * prepare() is application-agnostic - the SHM publisher consumes the same + * prepared diff (getChangedFunctions()/getChangedMethods() plus the image + * handle) and replaces only the apply() target, per-process tables today, ZCSG + * tomorrow. + */ +final class CacheImageSync +{ + /** @var array Lc name => live entry to swap */ + private array $changedFunctionEntries = []; + + /** @var array Lc name => image donor function */ + private array $changedFunctionImages = []; + + /** @var array Lc name => live entry was opcache-shared at prepare time */ + private array $functionWasShared = []; + + /** @var array Lc class => live class with changed methods */ + private array $changedClassEntries = []; + + /** @var array> Lc class => lc method => image method */ + private array $changedMethodImages = []; + + /** @var array Lc class => live class was opcache-shared at prepare time */ + private array $classWasShared = []; + + /** @var list<\ReflectionException> Refusals the diff detected; apply() throws the first */ + private array $refusals = []; + + /** @var list */ + private array $unchangedFunctions = []; + + /** @var list */ + private array $unchangedMethods = []; + + /** @var list */ + private array $notLoadedFunctions = []; + + /** @var list */ + private array $notLoadedClasses = []; + + /** @var list */ + private array $notLoadedMethods = []; + + private bool $isApplied = false; + + /** + * Materialized donors pinned for the lifetime of this sync: the swapped-in bodies + * execute out of the blocks these own (see ImageFunctionDonor and the retained + * $image, whose relocated buffer the bodies keep referencing) + * + * @var list + */ + // @phpstan-ignore property.onlyWritten (pure lifetime retention) + private array $materializedDonors = []; + + private function __construct(private readonly ReflectionOpcacheFile $image) {} + + /** + * Diffs a relocated cache image against the live process (read-only) + * + * The equality basis is ImageFunctionDonor::bodiesEqual(): body metrics, + * canonicalized opcodes, literal and static-default values. Refusals the diff + * detects (an image entry colliding with an internal function/class, changed + * methods of an enum/interface/trait) are recorded and thrown by apply() - + * preparing stays side-effect free so the plan can be introspected first. + */ + public static function prepare(ReflectionOpcacheFile $image): self + { + $sync = new self($image); + $sync->diffFunctions(); + $sync->diffClasses(); + + return $sync; + } + + /** + * @return list Lowercased names of global functions whose body will be swapped + */ + public function getChangedFunctions(): array + { + return array_keys($this->changedFunctionEntries); + } + + /** + * @return array> Lc class name => lc method names whose body will be swapped + */ + public function getChangedMethods(): array + { + return array_map(array_keys(...), $this->changedMethodImages); + } + + /** + * Human-readable reasons apply() will refuse this plan with, in throw order + * + * Empty when the plan is applicable. The first reason is what apply() throws + * (as its typed exception); listing them here keeps the refusal introspectable + * before anything is attempted. + * + * @return list + */ + public function getRefusalReasons(): array + { + return array_map( + static fn(\ReflectionException $refusal): string => $refusal->getMessage(), + $this->refusals, + ); + } + + /** + * Checks if applying this sync would perform no operation and refuse nothing + */ + public function isEmpty(): bool + { + return $this->changedFunctionEntries === [] + && $this->changedMethodImages === [] + && $this->refusals === []; + } + + /** + * Applies every changed body to the live process, atomically for the batch + * + * Order of operations: refusal validation first (nothing is touched when the plan + * contains a refused entry), then the copy-out of every opcache-shared target, + * then all body swaps - functions before classes, alphabetically within each + * group - staged so that a failing swap rolls every already-staged one back. + * A completed copy-out is NOT undone by that rollback: it is behavior-preserving + * on its own (the writable copy publishes the same bodies) and the documented + * copy-out caveats of docs/hot-swap.md apply from that moment on. + * + * @throws HotSwapException When the plan contains a refused entry, this sync was + * already applied, or a swap failed and was rolled back + * @throws SharedMemoryException When an opcache-shared target cannot be copied out of + * shared memory (preloaded class, unsupported class shape) + */ + public function apply(): CacheImageSyncReport + { + if ($this->isApplied) { + throw HotSwapException::imageAlreadyApplied($this->image->getFileName()); + } + if (Core::isShutdown()) { + throw HotSwapException::shutdown(); + } + if ($this->refusals !== []) { + throw $this->refusals[0]; + } + + // Copy-out pass: after this, every target entry is writable per-process memory. + // SharedMemoryException from here aborts the apply before any body changed. + foreach ($this->changedFunctionEntries as $liveFunction) { + $liveFunction->copyEntryOutOfSharedMemory(); + } + foreach ($this->changedClassEntries as $liveClass) { + $liveClass->copyOutOfSharedMemory(); + } + + // Materialization pass: allocation and normalization only, nothing published + $functionDonors = []; + foreach ($this->changedFunctionImages as $functionName => $imageFunction) { + $functionDonors[$functionName] = ImageFunctionDonor::materialize($imageFunction); + } + $methodDonors = []; + foreach ($this->changedMethodImages as $classKey => $imageMethods) { + foreach ($imageMethods as $methodName => $imageMethod) { + $methodDonors[$classKey][$methodName] = ImageFunctionDonor::materialize($imageMethod); + } + } + + // Staging pass: every entry dispatches the new body once its swap is staged, + // and any failure rolls all staged entries back to their previous bodies + /** @var list $pendingSwaps */ + $pendingSwaps = []; + try { + foreach ($functionDonors as $functionName => $donor) { + $entryFunction = $this->changedFunctionEntries[$functionName]; + $pendingSwaps[] = FunctionBodySwap::swapUserFunctionBody( + $entryFunction, + $donor->getDonor(), + // The entry keeps its declaration identity; only the body travels + preserveDeclaration: true, + // The image defaults table is pinned by the image buffer, not donor-owned + duplicateStatics: false, + // A shared-memory previous body is immortal and must not be freed + destroyPrevious: !$this->functionWasShared[$functionName], + publishedShares: FunctionBodySwap::countPublishedShares($entryFunction), + ); + } + foreach ($methodDonors as $classKey => $donors) { + $liveClass = $this->changedClassEntries[$classKey]; + $methodTable = $liveClass->getMethodTable(); + foreach ($donors as $methodName => $donor) { + // Re-resolved AFTER the copy-out pass: the published entry of a + // copied-out class is the writable duplicate, not the SHM original + $methodValue = $methodTable->find($methodName); + if ($methodValue === null) { + throw SharedMemoryException::methodMissingAfterCopyOut((string) $liveClass->getName(), $methodName); + } + $entryMethod = ReflectionMethod::fromRawEntry($methodValue->getRawFunction()); + $pendingSwaps[] = FunctionBodySwap::swapUserFunctionBody( + $entryMethod, + $donor->getDonor(), + preserveDeclaration: true, + duplicateStatics: false, + destroyPrevious: !$this->classWasShared[$classKey], + publishedShares: FunctionBodySwap::countPublishedShares($entryMethod), + ); + } + } + } catch (\Throwable $error) { + foreach (array_reverse($pendingSwaps) as $pending) { + $pending->rollback(); + } + if ($error instanceof HotSwapException || $error instanceof SharedMemoryException) { + throw $error; + } + throw HotSwapException::imageApplyFailedAndRolledBack($this->image->getFileName(), $error); + } + + // Commit: from here on nothing can fail - previous bodies are released + // (shared-memory ones stay allocated by contract) + foreach ($pendingSwaps as $pending) { + $pending->commit(); + } + $this->isApplied = true; + foreach ($functionDonors as $donor) { + $this->materializedDonors[] = $donor; + } + $appliedMethods = []; + foreach ($methodDonors as $classKey => $donors) { + foreach ($donors as $methodName => $donor) { + $this->materializedDonors[] = $donor; + $appliedMethods[] = "{$classKey}::{$methodName}"; + } + } + + return new CacheImageSyncReport( + $this->image->getFileName(), + array_keys($functionDonors), + $appliedMethods, + $this->unchangedFunctions, + $this->unchangedMethods, + $this->notLoadedFunctions, + $this->notLoadedClasses, + $this->notLoadedMethods, + ); + } + + /** + * Diffs every image function against the live function table + */ + private function diffFunctions(): void + { + $liveFunctionTable = Core::$executor->functionTable; + $imageFunctions = $this->image->getFunctions(); + ksort($imageFunctions); + foreach ($imageFunctions as $functionName => $imageFunction) { + $liveValue = $liveFunctionTable->find($functionName); + if ($liveValue === null) { + $this->notLoadedFunctions[] = $functionName; + continue; + } + $liveFunction = ReflectionFunction::fromCData($liveValue->getRawFunction()); + if (!$liveFunction->isUserDefined()) { + // An image body cannot replace a native handler, and the two are never + // "equal" - this is a refusal, not a skip (throw-or-work, never silent) + $this->refusals[] = HotSwapException::internalFunctionCollision($functionName); + continue; + } + if (ImageFunctionDonor::bodiesEqual($imageFunction, $liveFunction)) { + $this->unchangedFunctions[] = $functionName; + continue; + } + $this->changedFunctionEntries[$functionName] = $liveFunction; + $this->changedFunctionImages[$functionName] = $imageFunction; + $this->functionWasShared[$functionName] = $liveFunction->isImmutable(); + } + } + + /** + * Diffs every method an image class declares against the live class + */ + private function diffClasses(): void + { + $liveClassTable = Core::$executor->classTable; + $imageClasses = $this->image->getClasses(); + ksort($imageClasses); + foreach ($imageClasses as $classKey => $imageClass) { + $liveValue = $liveClassTable->find($classKey); + if ($liveValue === null) { + $this->notLoadedClasses[] = $classKey; + continue; + } + $liveClass = ReflectionClass::fromCData($liveValue->getRawClass()); + if (!$liveClass->isUserDefined()) { + $this->refusals[] = HotSwapException::internalClass((string) $liveClass->getName()); + continue; + } + + $changedMethods = $this->diffClassMethods($classKey, $imageClass, $liveClass); + if ($changedMethods === []) { + continue; + } + // Refusals gate MUTATION: an unchanged enum/interface/trait in the image is + // simply not an operation, only changed bodies of one are refused + $specialMask = Core::ZEND_ACC_INTERFACE | Core::ZEND_ACC_TRAIT | Core::ZEND_ACC_ENUM; + if ((($liveClass->getFlags() | $imageClass->getFlags()) & $specialMask) !== 0) { + $this->refusals[] = HotSwapException::unsupportedKind((string) $liveClass->getName()); + continue; + } + if (($liveClass->getFlags() & Core::ZEND_ACC_LINKED) === 0) { + $this->refusals[] = HotSwapException::notLinked((string) $liveClass->getName()); + continue; + } + $this->changedClassEntries[$classKey] = $liveClass; + $this->changedMethodImages[$classKey] = $changedMethods; + $this->classWasShared[$classKey] = $liveClass->isImmutable(); + } + } + + /** + * Diffs the methods one image class declares against the live class entry + * + * @return array Lc method name => image method with a changed body + */ + private function diffClassMethods(string $classKey, ReflectionClass $imageClass, ReflectionClass $liveClass): array + { + // A method-less class stores an UNINITIALIZED method table in the image + // (no bucket array to iterate) - and declares nothing to diff anyway + if (count($imageClass->getMethodTable()) === 0) { + return []; + } + $changedMethods = []; + $liveMethodTable = $liveClass->getMethodTable(); + $liveAddress = $liveClass->getAddress(); + $imageMethods = $imageClass->getDeclaredMethods(); + ksort($imageMethods); + foreach ($imageMethods as $methodName => $imageMethod) { + $liveMethodValue = $liveMethodTable->find($methodName); + if ($liveMethodValue === null) { + // Declared only in the image (a patch added it): out of the bridge's + // scope - the next include of the patched binary publishes it + $this->notLoadedMethods[] = "{$classKey}::{$methodName}"; + continue; + } + $liveMethod = ReflectionMethod::fromRawEntry($liveMethodValue->getRawFunction()); + if (!$liveMethod->isUserDefined()) { + $this->refusals[] = HotSwapException::internalMethodCollision($classKey, $methodName); + continue; + } + $liveScope = $liveMethod->getCommonPointer()->scope; + if ($liveScope === null || Core::addressOf($liveScope) !== $liveAddress) { + // The live table publishes an INHERITED entry under this name: swapping + // it would mutate the ancestor's method for every subclass. The image + // method is an override that only the next include can add. + $this->notLoadedMethods[] = "{$classKey}::{$methodName}"; + continue; + } + if (ImageFunctionDonor::bodiesEqual($imageMethod, $liveMethod)) { + $this->unchangedMethods[] = "{$classKey}::{$methodName}"; + continue; + } + $changedMethods[$methodName] = $imageMethod; + } + + return $changedMethods; + } +} diff --git a/src/HotSwap/CacheImageSyncReport.php b/src/HotSwap/CacheImageSyncReport.php new file mode 100644 index 00000000..73c33048 --- /dev/null +++ b/src/HotSwap/CacheImageSyncReport.php @@ -0,0 +1,62 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\HotSwap; + +/** + * What one CacheImageSync::apply() run did to the live process, entry by entry + * + * The bridge never no-ops silently (the fail policy of the opcache epic is + * throw-or-work): every entry of the image lands in exactly one of these + * buckets, and everything the bridge REFUSES to apply throws out of apply() + * instead of appearing here. Names are the canonical lowercase table keys; + * methods are reported as "class::method". + * + * The "not loaded" buckets are image entries the live process has no + * counterpart for (the script - or a method added only in the image - was + * never loaded here): they cannot be hot-swapped, only the next include of the + * patched binary picks them up (BinaryCacheFile::refresh()). + */ +final class CacheImageSyncReport +{ + /** + * @param string $scriptFile Source path the synced image caches + * @param list $appliedFunctions Global functions whose live body was swapped + * @param list $appliedMethods Methods whose live body was swapped + * @param list $unchangedFunctions Live bodies already equal to the image + * @param list $unchangedMethods Live method bodies already equal to the image + * @param list $notLoadedFunctions Image functions the live process never loaded + * @param list $notLoadedClasses Image classes the live process never loaded + * @param list $notLoadedMethods Image methods the live class does not declare + * + * @internal built by CacheImageSync::apply() + */ + public function __construct( + public readonly string $scriptFile, + public readonly array $appliedFunctions, + public readonly array $appliedMethods, + public readonly array $unchangedFunctions, + public readonly array $unchangedMethods, + public readonly array $notLoadedFunctions, + public readonly array $notLoadedClasses, + public readonly array $notLoadedMethods, + ) {} + + /** + * Checks if the run swapped nothing (every live counterpart already matched) + */ + public function isNoOp(): bool + { + return $this->appliedFunctions === [] && $this->appliedMethods === []; + } +} diff --git a/src/HotSwap/HotSwapException.php b/src/HotSwap/HotSwapException.php index d936c705..5cec83dd 100644 --- a/src/HotSwap/HotSwapException.php +++ b/src/HotSwap/HotSwapException.php @@ -120,6 +120,40 @@ public static function constantRemoved(string $className, string $constantName): ); } + public static function internalFunctionCollision(string $functionName): self + { + return new self( + "Cannot apply the cache image body of {$functionName}(): the live process publishes " + . 'an internal function under that name', + ); + } + + public static function internalMethodCollision(string $className, string $methodName): self + { + return new self( + "Cannot apply the cache image body of {$className}::{$methodName}(): the live class " + . 'publishes an internal function under that method name', + ); + } + + public static function imageAlreadyApplied(string $scriptFile): self + { + return new self( + "The cache image of {$scriptFile} has already been applied by this sync - " + . 'prepare a fresh CacheImageSync to re-diff the image against the live process', + ); + } + + public static function imageApplyFailedAndRolledBack(string $scriptFile, \Throwable $cause): self + { + return new self( + "Applying the cache image of {$scriptFile} failed and every staged body swap " + . "was rolled back: {$cause->getMessage()}", + 0, + $cause, + ); + } + public static function shutdown(): self { return new self('Cannot apply a class delta after Core::shutdown()'); diff --git a/src/OpCache/BinaryCacheFile.php b/src/OpCache/BinaryCacheFile.php index fe903376..8341ce7a 100644 --- a/src/OpCache/BinaryCacheFile.php +++ b/src/OpCache/BinaryCacheFile.php @@ -236,7 +236,9 @@ public function getReflection(): ReflectionOpcacheFile $this->relocator = new PayloadRelocator($buffer, $this->metaInfo); - return $this->view = new ReflectionOpcacheFile($this->relocator->relocate()); + // The relocator travels with the view as its owner, so the view alone keeps the + // buffer alive even when this BinaryCacheFile is released by the caller + return $this->view = new ReflectionOpcacheFile($this->relocator->relocate(), $this->relocator); } /** @@ -250,7 +252,22 @@ public function getReflection(): ReflectionOpcacheFile public function save(?string $binPath = null, ?int $timestamp = null, ?int $directoryPermissions = 0o755): void { $target = $binPath ?? $this->binPath; - if ($this->relocator !== null) { + if ($this->view !== null && $this->view->isGraphGrown()) { + // A mutation outgrew the original buffer (added function/method, + // regrown hashtable): re-emit the whole graph from scratch through + // the two-pass persist serializer (issue #117). In-place edits keep + // taking the exact-inverse derelocate() path below. + $serializer = new ScriptSerializer($this->view->getRawScript()); + $this->payload = $serializer->serialize(); + $this->metaInfo = CacheMetaInfo::forPayload( + systemId: $this->metaInfo->systemId(), + memSize: $serializer->memSize(), + strSize: strlen($this->payload) - $serializer->memSize(), + scriptOffset: $serializer->scriptOffset(), + timestamp: $this->metaInfo->timestamp(), + checksum: 0, // recomputed below + ); + } elseif ($this->relocator !== null) { // Re-serialize the (possibly mutated) live image, updating the // interned-string section size in the header $this->payload = $this->relocator->derelocate(); @@ -297,13 +314,28 @@ private static function ensureDirectory(string $directory, ?int $permissions): v } /** - * Writes the (patched) binary and invalidates opcache's in-memory copy of - * the source script, so the next include picks the patched binary up. + * Invalidates opcache's in-memory copy of the source script and writes the + * (patched) binary, so the next load picks the patched binary up. * * A script already resident in shared memory is not re-read until it is * invalidated, which is what this does; under opcache.file_cache_only there - * is no shared copy and the write alone suffices. Invalidation is a no-op - * when opcache is not active in this process (the write still happens). + * is no shared copy and the write alone suffices (opcache_invalidate() is + * a no-op there, and also when opcache is not active in this process - the + * write still happens either way). + * + * The order is deliberately invalidate-BEFORE-save: in a process running + * shared memory WITH opcache.file_cache, opcache_invalidate() also unlinks + * the script's cache binary (zend_file_cache_invalidate), so invalidating + * after the write would delete the patched binary it just produced (issue + * #252). With this order the unlink hits the stale binary, and the worst + * case when save() then fails is a cache miss - a recompile of the + * original source - never a silently lost patch. + * + * Same-process pickup caveat: after an in-process invalidation, opcache's + * default key lookup skips path resolution for the invalidated entry and + * never consults the file cache again - the patched binary is loaded on a + * re-include only under opcache.revalidate_path=1; a fresh worker picks it + * up with default settings. * * @param int|null $timestamp Source mtime to stamp; defaults to the script's * current mtime so the binary stays valid under @@ -314,10 +346,10 @@ public function refresh(?int $timestamp = null): void if ($timestamp === null && $this->scriptPath !== null && is_file($this->scriptPath)) { $timestamp = filemtime($this->scriptPath) ?: null; } - $this->save(null, $timestamp); - if ($this->scriptPath !== null && function_exists('opcache_invalidate')) { opcache_invalidate($this->scriptPath, true); } + + $this->save(null, $timestamp); } } diff --git a/src/OpCache/ImageFunctionDonor.php b/src/OpCache/ImageFunctionDonor.php new file mode 100644 index 00000000..0a4f6b5d --- /dev/null +++ b/src/OpCache/ImageFunctionDonor.php @@ -0,0 +1,412 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use FFI\CData; +use ZEngine\Core; +use ZEngine\Generated\zend_function; +use ZEngine\Generated\zend_op; +use ZEngine\Generated\zend_op_array; +use ZEngine\Generated\znode_op; +use ZEngine\Generated\zval; +use ZEngine\Reflection\FunctionLikeInterface; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Reflection\ReflectionValue; +use ZEngine\System\OpCode; +use ZEngine\Type\HashTable; +use ZEngine\Type\OpLine; +use ZEngine\Type\StructArray; + +/** + * Turns one function body of a relocated cache image into an executable donor + * for the in-place body-swap machinery (FunctionBodySwap), and provides the + * body-equality basis the cache-image bridge diffs with. + * + * A relocated image (PayloadRelocator) is structurally walkable but NOT + * executable: the relocator deliberately preserves the two execution-only + * encodings opcache stores in the file byte-for-byte, because the engine + * re-derives them when IT loads the binary (zend_file_cache_unserialize): + * + * - every opline's handler is the serialized INDEX into the VM handler table + * (zend_serialize_opcode_handler), not a callable handler pointer; + * - every IS_CONST operand is the literal's INDEX into op_array->literals, + * not the runtime relative-offset form RT_CONSTANT() dereferences on this + * platform (!ZEND_USE_ABS_CONST_ADDR). + * + * materialize() performs exactly the engine's own normalization, on a copy: + * the opcode array and the literal table are copied side by side into one + * process-owned block (the runtime constant form is a signed 32-bit offset + * from the opline, so both arrays must stay within one allocation, mirroring + * the engine's own co-allocation), IS_CONST operands are rewritten to that + * form and the handlers are restored through the engine's own + * zend_deserialize_opcode_handler(). The image buffer itself is never written, + * so PayloadRelocator::derelocate() (BinaryCacheFile::save()) keeps producing + * a valid binary after donors were materialized from the image. + * + * Everything else in the donor body - literal payloads (strings, immutable + * arrays), CV name table, arg_info, static-variable defaults - keeps pointing + * INTO the image buffer, exactly like the engine executes a file_cache_only + * script straight out of its load buffer. Two lifetime rules follow + * (docs/long-running.md): + * + * - the image buffer and the materialized block must outlive every entry the + * donor body was swapped into; both are request-lifetime allocations that + * are never explicitly freed, and this object pins the CData handles; + * - the donor op_array carries NO refcount (opcache persisted it that way), + * so the engine never destroys the swapped-in body: destroy_op_array + * releases only the entry's name and heap run-time cache, the same + * contract shared-memory bodies follow. + * + * @internal core-layer machinery of the cache-image bridge (CacheImageSync) + */ +final class ImageFunctionDonor +{ + /** + * fn_flags that describe how a body is STORED, not what it does: they legitimately + * differ between a serialized image body and the live entry compiled from the same + * source, so the equality basis masks them out. IMMUTABLE marks opcache-shared + * storage; HEAP_RT_CACHE marks a per-entry heap run-time cache (set by every swap). + */ + private const int STORAGE_ONLY_FLAGS = Core::ZEND_ACC_IMMUTABLE | Core::ZEND_ACC_HEAP_RT_CACHE; + + /** + * Opcodes whose op1 operand is the object RECEIVER: an IS_UNUSED op1 there means + * the implicit $this, and the compiler copies an UNINITIALIZED znode into the + * operand (zend_compile.c: zend_delayed_compile_prop() and the method-call path + * only set obj_node.op_type when this_guaranteed_exists()), so op1.num holds + * nondeterministic stack garbage. The VM never reads it for these opcodes, and the + * equality basis must ignore it - it differs between any two compilations. + * + * Deliberately UNTYPED (no `const array`): a typed array constant whose value is a + * constant expression trips the debug-build engine assertion + * `zend_update_class_constant: !EG(exception)` when this library is preloaded + * (opcache.preload evaluates the AST during preload linking) - the untyped form, + * like ClassDelta::MAGIC_METHOD_NAMES, preloads cleanly on release and debug alike. + * + * @var list + */ + private const THIS_RECEIVER_OPCODES = [ + OpCode::ASSIGN_OBJ, + OpCode::ASSIGN_OBJ_OP, + OpCode::ASSIGN_OBJ_REF, + OpCode::UNSET_OBJ, + OpCode::FETCH_OBJ_R, + OpCode::FETCH_OBJ_W, + OpCode::FETCH_OBJ_RW, + OpCode::FETCH_OBJ_IS, + OpCode::FETCH_OBJ_FUNC_ARG, + OpCode::FETCH_OBJ_UNSET, + OpCode::INIT_METHOD_CALL, + OpCode::PRE_INC_OBJ, + OpCode::PRE_DEC_OBJ, + OpCode::POST_INC_OBJ, + OpCode::POST_DEC_OBJ, + OpCode::ISSET_ISEMPTY_PROP_OBJ, + ]; + + /** + * @param ReflectionFunction $donorFunction Pointer-level view of the donor container + * @param CData|zend_function $container zend_function the swap machinery copies from + * @param CData $bodyBlock [opcodes][literals] block the donor points into + */ + private function __construct( + private readonly ReflectionFunction $donorFunction, + // @phpstan-ignore property.onlyWritten (pure lifetime retention until the swap commits) + private readonly object $container, + // @phpstan-ignore property.onlyWritten (pure lifetime retention: the swapped-in body executes out of it) + private readonly object $bodyBlock, + ) {} + + /** + * Materializes an executable donor from an image function (see class docblock) + * + * The returned object OWNS the donor: it must stay referenced at least until the + * body swap consuming the donor has committed (the zend_function container bytes + * are copied into the live entry by the swap), and the swapped-in body keeps + * executing out of the pinned block and the image buffer afterwards. + * + * @param FunctionLikeInterface $imageFunction User function of a relocated image + */ + public static function materialize(FunctionLikeInterface $imageFunction): self + { + $imageOpArray = $imageFunction->getOpArrayPointer(); + $opSize = Core::sizeOfType(zend_op::class); + $zvalSize = Core::sizeOfType(zval::class); + $opcodesBytes = $imageOpArray->last * $opSize; + $literalBytes = $imageOpArray->last_literal * $zvalSize; + + // One co-allocated [opcodes][literals] block: the runtime IS_CONST form is a + // signed 32-bit opline-relative offset, so the literal table must live next to + // the opcodes (the engine co-allocates them for the same reason). The block is + // request memory that is never explicitly freed - the refcount-less body stays + // published until request end, where table teardown provably never reads it + // (destroy_op_array returns before touching opcodes of a refcount-less body). + assert($imageOpArray->opcodes !== null && $opcodesBytes > 0); + $bodyBlock = Core::new('char[' . ($opcodesBytes + $literalBytes) . ']', false); + $blockAddress = Core::addressOf(Core::addr($bodyBlock)); + $literalsBase = $blockAddress + $opcodesBytes; + Core::memcpy($bodyBlock, $imageOpArray->opcodes, $opcodesBytes); + if ($literalBytes > 0) { + assert($imageOpArray->literals !== null); + $literalsTarget = Core::pointerAtAddress('char *', $literalsBase); + Core::memcpy($literalsTarget, $imageOpArray->literals, $literalBytes); + } + + for ($index = 0; $index < $imageOpArray->last; $index++) { + $oplineAddress = $blockAddress + $index * $opSize; + /** @var zend_op $opline */ + $opline = Core::pointerAtAddress('zend_op *', $oplineAddress); + if ($opline->op1_type === OpLine::IS_CONST) { + $opline->op1->constant = ($literalsBase + $opline->op1->constant * $zvalSize) - $oplineAddress; + } + if ($opline->op2_type === OpLine::IS_CONST) { + $opline->op2->constant = ($literalsBase + $opline->op2->constant * $zvalSize) - $oplineAddress; + } + // The engine's own index -> handler-pointer restoration (zend_vm.h), the + // exact call zend_file_cache_unserialize_op_array performs per opline + Core::call('zend_deserialize_opcode_handler', $opline); + } + + // The donor container is a writable copy of the image zend_function: the image + // struct itself is never written, so save()/derelocate() stays valid. The swap + // machinery copies the container bytes into the live entry wholesale. + $container = Core::new(zend_function::class); + Core::memcpy($container, $imageFunction->getEntryPointer(), Core::sizeOfType(zend_function::class)); + $donorFunction = ReflectionFunction::fromCData(Core::cast('zend_function *', Core::addr($container))); + $donorOpArray = $donorFunction->getOpArrayPointer(); + /** @var zend_op $blockOpcodes Narrowed at the boundary: the block starts with the opcode array */ + $blockOpcodes = Core::pointerAtAddress('zend_op *', $blockAddress); + $donorOpArray->opcodes = $blockOpcodes; + if ($literalBytes > 0) { + /** @var zval $blockLiterals Narrowed at the boundary: literals follow the opcodes */ + $blockLiterals = Core::pointerAtAddress('zval *', $literalsBase); + $donorOpArray->literals = $blockLiterals; + } + // The donor is a per-process body, not opcache-shared storage: without this the + // swapped-in entry would advertise ZEND_ACC_IMMUTABLE semantics (map-ptr offset + // statics slot, refusal of in-place mutation) it does not actually have + $donorFunction->getCommonPointer()->fn_flags &= ~Core::ZEND_ACC_IMMUTABLE; + + return new self($donorFunction, $container, $bodyBlock); + } + + /** + * The materialized donor, ready for FunctionBodySwap::swapUserFunctionBody() + */ + public function getDonor(): ReflectionFunction + { + return $this->donorFunction; + } + + /** + * Compares an image function body against a live entry's compiled body + * + * The equality basis is: the body metrics (opcode/literal/CV/temporary counts, + * argument counts), the fn_flags word without the storage-only bits, the CV name + * table, every opline in canonicalized form (IS_CONST operands compared by literal + * INDEX - derived from the runtime relative-offset form on the live side - and the + * handler ignored, since it is storage-form-specific), every literal by value + * (ReflectionValue::equals) and the static-variable defaults table by value. + * + * The comparison is deliberately conservative where value equality cannot be + * proven cheaply: array and constant-expression literals (and static defaults) + * always count as different, exactly like ReflectionMethod::equals(). A body that + * carries them is re-applied by every sync even when it did not change - a safe + * false POSITIVE. Known false NEGATIVES are declaration-surface-only edits the + * bridge does not model: arg_info type/name changes and doc comments do not enter + * the comparison (see docs/opcache-binary.md). + * + * @param FunctionLikeInterface $imageFunction User function of a relocated image (serialized opline form) + * @param FunctionLikeInterface $liveFunction Live user function published in an executor table (runtime form) + */ + public static function bodiesEqual(FunctionLikeInterface $imageFunction, FunctionLikeInterface $liveFunction): bool + { + $imageOpArray = $imageFunction->getOpArrayPointer(); + $liveOpArray = $liveFunction->getOpArrayPointer(); + + $metricsAgree = $imageOpArray->last === $liveOpArray->last + && $imageOpArray->last_var === $liveOpArray->last_var + && $imageOpArray->last_literal === $liveOpArray->last_literal + && $imageOpArray->T === $liveOpArray->T + && $imageOpArray->num_args === $liveOpArray->num_args + && $imageOpArray->required_num_args === $liveOpArray->required_num_args + && ($imageOpArray->fn_flags & ~self::STORAGE_ONLY_FLAGS) === ($liveOpArray->fn_flags & ~self::STORAGE_ONLY_FLAGS); + if (!$metricsAgree) { + return false; + } + if ($imageFunction->getVariableNames() !== $liveFunction->getVariableNames()) { + return false; + } + + return self::opcodesEqual($imageOpArray, $liveOpArray) + && self::literalsEqual($imageOpArray, $liveOpArray) + && self::staticDefaultsEqual($imageOpArray, $liveOpArray); + } + + /** + * Opline-by-opline comparison across the two storage forms (equal counts assumed) + * + * @param zend_op_array $imageOpArray Serialized form: IS_CONST operands hold literal indexes + * @param zend_op_array $liveOpArray Runtime form: IS_CONST operands hold opline-relative offsets + */ + private static function opcodesEqual(object $imageOpArray, object $liveOpArray): bool + { + $opSize = Core::sizeOfType(zend_op::class); + $zvalSize = Core::sizeOfType(zval::class); + assert($imageOpArray->opcodes !== null && $liveOpArray->opcodes !== null); + $imageBase = Core::addressOf($imageOpArray->opcodes); + $liveBase = Core::addressOf($liveOpArray->opcodes); + $liveLiteralsAddress = $liveOpArray->literals !== null ? Core::addressOf($liveOpArray->literals) : 0; + + for ($index = 0; $index < $imageOpArray->last; $index++) { + /** @var zend_op $imageOpline */ + $imageOpline = Core::pointerAtAddress('zend_op *', $imageBase + $index * $opSize); + /** @var zend_op $liveOpline */ + $liveOpline = Core::pointerAtAddress('zend_op *', $liveBase + $index * $opSize); + + $shapeAgrees = $imageOpline->opcode === $liveOpline->opcode + && $imageOpline->op1_type === $liveOpline->op1_type + && $imageOpline->op2_type === $liveOpline->op2_type + && $imageOpline->result_type === $liveOpline->result_type + && $imageOpline->extended_value === $liveOpline->extended_value + && $imageOpline->lineno === $liveOpline->lineno + && $imageOpline->result->num === $liveOpline->result->num; + if (!$shapeAgrees) { + return false; + } + + $liveOplineAddress = $liveBase + $index * $opSize; + $skipReceiverNoise = $imageOpline->op1_type === OpLine::IS_UNUSED + && in_array($imageOpline->opcode, self::THIS_RECEIVER_OPCODES, true); + if (!$skipReceiverNoise) { + $op1Agrees = self::operandsEqual( + $imageOpline->op1_type, + $imageOpline->op1, + $liveOpline->op1, + $liveOplineAddress, + $liveLiteralsAddress, + $zvalSize, + ); + if (!$op1Agrees) { + return false; + } + } + $op2Agrees = self::operandsEqual( + $imageOpline->op2_type, + $imageOpline->op2, + $liveOpline->op2, + $liveOplineAddress, + $liveLiteralsAddress, + $zvalSize, + ); + if (!$op2Agrees) { + return false; + } + } + + return true; + } + + /** + * Compares one operand across the two storage forms + * + * @param znode_op $imageOperand Serialized form: an IS_CONST operand holds the literal index + * @param znode_op $liveOperand Runtime form: an IS_CONST operand holds the opline-relative offset + */ + private static function operandsEqual( + int $operandType, + object $imageOperand, + object $liveOperand, + int $liveOplineAddress, + int $liveLiteralsAddress, + int $zvalSize, + ): bool { + if ($operandType === OpLine::IS_CONST) { + // Canonical form is the literal index: the live side stores the + // opline-relative byte offset of the literal (RT_CONSTANT form) + $liveOffset = self::toSignedInt32($liveOperand->constant); + $liveIndex = intdiv($liveOplineAddress + $liveOffset - $liveLiteralsAddress, $zvalSize); + + return $imageOperand->constant === $liveIndex; + } + + return $imageOperand->num === $liveOperand->num; + } + + /** + * Literal-table comparison by value (equal literal counts assumed) + * + * @param zend_op_array $imageOpArray + * @param zend_op_array $liveOpArray + */ + private static function literalsEqual(object $imageOpArray, object $liveOpArray): bool + { + $totalLiterals = $imageOpArray->last_literal; + if ($totalLiterals === 0) { + return true; + } + assert($imageOpArray->literals !== null && $liveOpArray->literals !== null); + $imageLiterals = new StructArray($imageOpArray->literals, $totalLiterals); + $liveLiterals = new StructArray($liveOpArray->literals, $totalLiterals); + for ($index = 0; $index < $totalLiterals; $index++) { + $imageValue = ReflectionValue::fromValueEntry($imageLiterals[$index]); + $liveValue = ReflectionValue::fromValueEntry($liveLiterals[$index]); + if (!$imageValue->equals($liveValue)) { + return false; + } + } + + return true; + } + + /** + * Static-variable DEFAULTS comparison by value (the declaration table, never the + * live per-process table a call may have materialized) + * + * @param zend_op_array $imageOpArray + * @param zend_op_array $liveOpArray + */ + private static function staticDefaultsEqual(object $imageOpArray, object $liveOpArray): bool + { + $imageDefaults = $imageOpArray->static_variables; + $liveDefaults = $liveOpArray->static_variables; + if (($imageDefaults === null) !== ($liveDefaults === null)) { + return false; + } + if ($imageDefaults === null || $liveDefaults === null) { + return true; + } + $imageTable = HashTable::fromCData($imageDefaults); + $liveTable = HashTable::fromCData($liveDefaults); + if (count($imageTable) !== count($liveTable)) { + return false; + } + foreach ($imageTable as $variableName => $imageValue) { + $liveValue = $liveTable->find((string) $variableName); + if ($liveValue === null || !$imageValue->equals($liveValue)) { + return false; + } + } + + return true; + } + + /** + * Reinterprets a uint32 field value as the signed 32-bit offset it stores + */ + private static function toSignedInt32(int $value): int + { + return $value >= 0x80000000 ? $value - 0x100000000 : $value; + } +} diff --git a/src/OpCache/OpCacheException.php b/src/OpCache/OpCacheException.php index c2daf78e..54e7cdfa 100644 --- a/src/OpCache/OpCacheException.php +++ b/src/OpCache/OpCacheException.php @@ -136,4 +136,41 @@ public static function payloadNotRelocated(): self { return new self('The payload is not relocated: call script() before accessing structures'); } + + /** + * The graph serializer met a pointer whose target no persisted unit covers - + * the graph references memory the serialization pass never absorbed + */ + public static function unresolvedGraphReference(string $what): self + { + return new self("Graph serialization failed, {$what}: the referenced structure was not persisted"); + } + + /** + * A stored offset, count or element span in the payload points outside the + * declared buffer bounds - the binary is truncated or crafted. Relocating it + * would be an out-of-bounds engine read/write, so the load is refused + * (issue #123). The bin must come from a trusted producer; system_id is a + * build fingerprint, not an authenticator, and adler32 is not tamper-proof. + */ + public static function malformedPayload(string $what): self + { + return new self("Malformed opcache payload: {$what}"); + } + + /** + * A graft donor does not contain the requested function/class/method + */ + public static function graftEntryNotFound(string $kind, string $name): self + { + return new self("Cannot graft {$kind} '{$name}': the donor cache image does not contain it"); + } + + /** + * The graft target hashtable already holds an entry under this key + */ + public static function duplicateHashTableKey(string $key): self + { + return new self("Cannot graft '{$key}': the target table already holds an entry under that key"); + } } diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index b466fa0c..2ba51970 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -17,22 +17,41 @@ use FFI\CData; use ZEngine\Core; use ZEngine\Generated\Bucket; +use ZEngine\Generated\HashTable as HashTableStruct; use ZEngine\Generated\zend_arg_info; use ZEngine\Generated\zend_ast; use ZEngine\Generated\zend_ast_list; use ZEngine\Generated\zend_ast_ref; +use ZEngine\Generated\zend_ast_zval; +use ZEngine\Generated\zend_attribute; use ZEngine\Generated\zend_attribute_arg; +use ZEngine\Generated\zend_class_arrayaccess_funcs; +use ZEngine\Generated\zend_class_constant; +use ZEngine\Generated\zend_class_entry; +use ZEngine\Generated\zend_class_iterator_funcs; use ZEngine\Generated\zend_class_name; use ZEngine\Generated\zend_early_binding; +use ZEngine\Generated\zend_error_info; +use ZEngine\Generated\zend_function; +use ZEngine\Generated\zend_op_array; +use ZEngine\Generated\zend_persistent_script; +use ZEngine\Generated\zend_property_info; use ZEngine\Generated\zend_string; +use ZEngine\Generated\zend_trait_alias; +use ZEngine\Generated\zend_trait_precedence; +use ZEngine\Generated\zend_type; +use ZEngine\Generated\zend_type_list; use ZEngine\Generated\zval; /** * Turns the position-independent file-cache payload into a live in-memory * image and back, a faithful port of ext/opcache/zend_file_cache.c - * (unserialize = {@see relocate}, serialize = {@see derelocate}) for the - * linux-x64 non-thread-safe build. Thread-safe (ZTS) payloads use a different - * binary layout and are rejected until issue #118 lands ZTS-specific walking. + * (unserialize = {@see relocate}, serialize = {@see derelocate}) for 64-bit + * POSIX builds, NTS and ZTS alike: zend_file_cache.c has no thread-safety + * conditionals, and every struct the walker dereferences is layout-identical + * across the two modes (only EG/CG/module_entry differ on ZTS, none of which + * appear in a payload) - verified against the generated layouts.json of both + * targets (issue #118). * * In the file every interior pointer is stored as a byte offset from the * buffer start (SERIALIZE_PTR) and every interned string as a tagged offset @@ -75,8 +94,18 @@ final class PayloadRelocator private const int TYPE_LIST_BIT = 4194304; // _ZEND_TYPE_LIST_BIT private const int TYPE_NAME_BIT = 16777216; // _ZEND_TYPE_NAME_BIT + /** ZEND_PROPERTY_HOOK_COUNT (zend_property_hooks.h) - get + set slots */ + private const int PROPERTY_HOOK_COUNT = 2; + private readonly int $base; private readonly int $size; + /** + * Upper bound for tagged interned-string offsets. Starts at the header's + * str_size and is re-pinned to the rebuilt string-section length whenever + * serialize() re-emits it, so the relocate() inside derelocate() validates + * against the section it just produced, not the stale original size. + */ + private int $strSize; private readonly int $strSectionBase; /** Interned-string re-emission state (write path) */ @@ -92,12 +121,12 @@ final class PayloadRelocator * Whether the relocator can handle payloads of the running build at all * * The exact predicate the constructor enforces, exposed so callers (and the tests - * covering them) can skip cleanly instead of provoking the throw. Windows payloads - * are tracked in issue #119, ZTS ones in issue #118. + * covering them) can skip cleanly instead of provoking the throw. Windows opcache + * support is an intentional non-goal (issue #119 was rescoped to macOS/arm64). */ public static function isSupported(): bool { - return PHP_INT_SIZE === 8 && \DIRECTORY_SEPARATOR === '/' && !\ZEND_THREAD_SAFE; + return PHP_INT_SIZE === 8 && \DIRECTORY_SEPARATOR === '/'; } /** @@ -109,28 +138,34 @@ public function __construct(private readonly object $buffer, private readonly Ca if (PHP_INT_SIZE !== 8 || \DIRECTORY_SEPARATOR !== '/') { throw OpCacheException::unsupportedPayload('the relocator supports 64-bit non-Windows builds only'); } - if (\ZEND_THREAD_SAFE) { - throw OpCacheException::unsupportedPayload( - 'ZTS file-cache payloads use a different binary layout - tracked in issue #118', - ); - } $this->base = Core::addressOf(Core::addr($buffer)); $this->size = $metaInfo->memSize(); + $this->strSize = $metaInfo->strSize(); $this->strSectionBase = $this->base + $this->size; - // _ZSTR_HEADER_SIZE = sizeof(zend_string) - sizeof(char) (the flexible val[1] member) - $this->zendStringHeaderSize = Core::sizeOfType(zend_string::class) - 1; + // _ZSTR_HEADER_SIZE = XtOffsetOf(zend_string, val): the flexible val[1] + // member starts at the last 8-byte slot of the (padded) struct, so the + // header is sizeof - 8, NOT sizeof - 1 (which over-copied 7 bytes per + // interned emission and diverged from _ZSTR_STRUCT_SIZE) + $this->zendStringHeaderSize = Core::sizeOfType(zend_string::class) - PHP_INT_SIZE; } /** * Rewrites the buffer in place, converting every stored offset to a real * address, and returns a typed pointer to the embedded zend_persistent_script. * - * @return \FFI\CData + * @return zend_persistent_script */ public function relocate(): object { $this->sharedOpcodes = []; - $script = Core::pointerAtAddress('zend_persistent_script *', $this->base + $this->metaInfo->scriptOffset()); + // The script struct itself must fit inside the mem region before we + // dereference a single field of it (issue #123) + $this->requireSpan( + $this->metaInfo->scriptOffset(), + Core::sizeOfType(zend_persistent_script::class), + 'zend_persistent_script at scriptOffset', + ); + $script = Core::pointerAtAddress(zend_persistent_script::class, $this->base + $this->metaInfo->scriptOffset()); $this->unStr($script->script, 'filename'); $this->unserializeHash($script->script->class_table, $this->unserializeClass(...)); @@ -167,7 +202,7 @@ private function serialize(): string $this->strSection = ''; $this->internedXlat = []; $this->sharedOpcodes = []; - $script = Core::pointerAtAddress('zend_persistent_script *', $this->base + $this->metaInfo->scriptOffset()); + $script = Core::pointerAtAddress(zend_persistent_script::class, $this->base + $this->metaInfo->scriptOffset()); $this->serStr($script->script, 'filename'); $this->serializeHash($script->script->class_table, $this->serializeClass(...)); @@ -176,19 +211,36 @@ private function serialize(): string $this->serializeWarnings($script); $this->serializeEarlyBindings($script); - $memRegion = FFI::string($this->buffer, $this->size); + // max(...,0) only states the non-negative mem-region size to the analyser + $memRegion = FFI::string($this->buffer, max($this->size, 0)); + // Re-pin the tagged-offset bound to the section just emitted, so the + // relocate() in derelocate() validates against it (issue #123) + $this->strSize = strlen($this->strSection); return $memRegion . $this->strSection; } // --- pointer/offset primitives (SERIALIZE_PTR / UNSERIALIZE_PTR) -------- + /** + * Reads a uintptr_t pointer slot as a PHP int - the raw-pointer read + * primitive. The dereferenced CData element is always an integer at runtime; + * the guard states that to the analyser without widening any real value. + * + * @param \FFI\CData $slot a uintptr_t* view over the slot to read + */ + private function readSlot(object $slot): int + { + $value = $slot[0]; + \assert(\is_int($value)); + + return $value; + } + /** * Reads a pointer field's stored value through a raw integer view of its * storage, so it works for every pointee type including void* (which * Core::addressOf cannot cast). 0 when the C NULL surfaces as PHP null. - * - * @param \FFI\CData $owner */ private function ptrValue(object $owner, string $field): int { @@ -196,15 +248,16 @@ private function ptrValue(object $owner, string $field): int return 0; } - return (int) Core::cast('uintptr_t *', FFI::addr($owner->$field))[0]; + // A dynamically-named pointer field cannot be statically resolved, so + // FFI::addr() on the mixed field read is the one irreducible CData hop. + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) + return $this->readSlot(Core::cast('uintptr_t *', FFI::addr($owner->$field))); } - /** - * @param \FFI\CData $owner - */ private function writePtrField(object $owner, string $field, int $address): void { - // Only ever called for a currently non-null field, so FFI::addr is safe + // Only ever called for a currently non-null field, so FFI::addr is safe. + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) $slot = Core::cast('uintptr_t *', FFI::addr($owner->$field)); $slot[0] = $address; } @@ -220,16 +273,87 @@ private function isUnserialized(int $pointer): bool return $pointer >= $this->base && $pointer <= $this->base + $this->size; } - /** UNSERIALIZE_PTR on a struct field, returning the resolved address (0 if null) */ + // --- bounds validation (issue #123) ------------------------------------- + // Every stored offset in the file is attacker-controllable in an untrusted + // binary (system_id is a build fingerprint, adler32 is forgeable), so each + // one is range-checked before it becomes a real address the engine walks. + // The checks live in the UNSERIALIZE (relocate) primitives and the + // count-driven relocate loops - the derelocate/serialize path and the graph + // serializer both operate on an already-relocated, in-process image and + // inherit that image's validation. + + /** + * Validates a stored mem-region offset lies in [0, size]. The upper bound is + * inclusive because a return-type-only &arg_info[1] legitimately points at + * the region end. Returns the offset for fluent use. + */ + private function requireOffset(int $stored, string $what): int + { + if ($stored < 0 || $stored > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: stored offset %d is outside [0, %d]', $what, $stored, $this->size), + ); + } + + return $stored; + } + + /** + * Validates a stored zend_string reference: a tagged interned reference must + * land in the appended string section [0, strSize), a plain one in the mem + * region [0, size]. + */ + private function requireStringOffset(int $stored, string $what): void + { + if (($stored & 1) !== 0) { + $offset = $stored & ~1; + if ($offset < 0 || $offset >= $this->strSize) { + throw OpCacheException::malformedPayload( + sprintf('%s: interned-string offset %d is outside [0, %d)', $what, $offset, $this->strSize), + ); + } + + return; + } + $this->requireOffset($stored, $what); + } + /** - * @param \FFI\CData $owner + * Validates that [resolvedAddress, resolvedAddress + count * elementSize) + * lies fully within the mem region, before a loop dereferences the span. + * A negative or overflowing count is rejected too. */ + private function requireSpan(int $offsetOrAddress, int $bytes, string $what): void + { + // Accept either a stored offset or a resolved (base+offset) address + $offset = $offsetOrAddress >= $this->base ? $offsetOrAddress - $this->base : $offsetOrAddress; + if ($bytes < 0 || $offset < 0 || $offset > $this->size || $offset + $bytes > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: span [%d, %d) escapes the %d-byte mem region', $what, $offset, $offset + $bytes, $this->size), + ); + } + } + + /** Validates a count field before it drives an element-span walk */ + private function requireCount(int $count, string $what): int + { + if ($count < 0 || $count > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: implausible count %d for a %d-byte region', $what, $count, $this->size), + ); + } + + return $count; + } + + /** UNSERIALIZE_PTR on a struct field, returning the resolved address (0 if null) */ private function unPtr(object $owner, string $field): int { $stored = $this->ptrValue($owner, $field); if ($stored === 0) { return 0; } + $this->requireOffset($stored, "pointer field {$field}"); $address = $this->base + $stored; $this->writePtrField($owner, $field, $address); @@ -237,9 +361,6 @@ private function unPtr(object $owner, string $field): int } /** SERIALIZE_PTR on a struct field, returning the pre-serialization address (0 if null) */ - /** - * @param \FFI\CData $owner - */ private function serPtr(object $owner, string $field): int { $address = $this->ptrValue($owner, $field); @@ -251,10 +372,34 @@ private function serPtr(object $owner, string $field): int return $address; } + /** UNSERIALIZE_PTR on a raw pointer slot, returning the resolved address (0 for a NULL slot) */ + private function unPtrAt(int $slotAddress): int + { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); + $stored = $this->readSlot($slot); + if ($stored === 0) { + return 0; + } + $this->requireOffset($stored, 'raw pointer slot'); + $slot[0] = $this->base + $stored; + + return $this->base + $stored; + } + + /** SERIALIZE_PTR on a raw pointer slot, returning the pre-serialization address (0 for a NULL slot) */ + private function serPtrAt(int $slotAddress): int + { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); + $address = $this->readSlot($slot); + if ($address === 0) { + return 0; + } + $slot[0] = $address - $this->base; + + return $address; + } + // --- interned-string primitives (UNSERIALIZE_STR / SERIALIZE_STR) ------ - /** - * @param \FFI\CData $owner - */ private function unStr(object $owner, string $field): void { @@ -262,6 +407,7 @@ private function unStr(object $owner, string $field): void if ($stored === 0) { return; } + $this->requireStringOffset($stored, "string field {$field}"); if (($stored & 1) !== 0) { // Tagged interned reference into the string section $address = $this->strSectionBase + ($stored & ~1); @@ -271,9 +417,6 @@ private function unStr(object $owner, string $field): void // GC flag normalization is deliberately skipped (see class docblock) $this->writePtrField($owner, $field, $address); } - /** - * @param \FFI\CData $owner - */ private function serStr(object $owner, string $field): void { @@ -290,6 +433,37 @@ private function serStr(object $owner, string $field): void $this->writePtrField($owner, $field, $this->emitInterned($address)); } + /** UNSERIALIZE_STR on a raw zend_string* slot (no owning struct field) */ + private function unStrAt(int $slotAddress): void + { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); + $stored = $this->readSlot($slot); + if ($stored === 0) { + return; + } + $this->requireStringOffset($stored, 'raw string slot'); + if (($stored & 1) !== 0) { + $slot[0] = $this->strSectionBase + ($stored & ~1); + } else { + $slot[0] = $this->base + $stored; + } + } + + /** SERIALIZE_STR on a raw zend_string* slot (no owning struct field) */ + private function serStrAt(int $slotAddress): void + { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress)); + $address = $this->readSlot($slot); + if ($address === 0) { + return; + } + if ($address >= $this->base && $address < $this->base + $this->size) { + $slot[0] = $address - $this->base; + } else { + $slot[0] = $this->emitInterned($address); + } + } + /** * Copies an interned string into the rebuilt string section (deduplicated) * and returns its tagged offset - the port of zend_file_cache_serialize_interned. @@ -299,24 +473,23 @@ private function emitInterned(int $address): int if (isset($this->internedXlat[$address])) { return $this->internedXlat[$address]; } - $stringPointer = Core::pointerAtAddress('zend_string *', $address); + $stringPointer = Core::pointerAtAddress(zend_string::class, $address); $length = $stringPointer->len; $structSize = Core::getAlignedSize($this->zendStringHeaderSize + $length + 1); $tagged = strlen($this->strSection) | 1; $this->internedXlat[$address] = $tagged; - $this->strSection .= FFI::string(Core::cast('char *', $stringPointer), $structSize); + // max(...,0) only states the non-negative aligned size to the analyser + $this->strSection .= FFI::string(Core::cast('char *', $stringPointer), max($structSize, 0)); return $tagged; } // --- hashes (zend_file_cache_(un)serialize_hash) ----------------------- - /** - * @param \FFI\CData $ht - */ private function unserializeHash(object $ht, callable $each): void { + /** @var HashTableStruct $ht Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if (($ht->u->flags & self::HASH_FLAG_UNINITIALIZED) !== 0) { return; } @@ -324,11 +497,12 @@ private function unserializeHash(object $ht, callable $each): void return; } $dataAddress = $this->unPtr($ht, 'arData'); - $used = $ht->nNumUsed; + $used = $this->requireCount((int) $ht->nNumUsed, 'hashtable nNumUsed'); if (($ht->u->flags & self::HASH_FLAG_PACKED) !== 0) { $zvalSize = Core::sizeOfType(zval::class); + $this->requireSpan($dataAddress, $used * $zvalSize, 'packed hashtable data'); for ($i = 0; $i < $used; $i++) { - $zval = Core::pointerAtAddress('zval *', $dataAddress + $i * $zvalSize); + $zval = Core::pointerAtAddress(zval::class, $dataAddress + $i * $zvalSize); if ($zval->u1->v->type !== 0) { $each($zval); } @@ -337,20 +511,19 @@ private function unserializeHash(object $ht, callable $each): void return; } $bucketSize = Core::sizeOfType(Bucket::class); + $this->requireSpan($dataAddress, $used * $bucketSize, 'hashtable bucket data'); for ($i = 0; $i < $used; $i++) { - $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); + $bucket = Core::pointerAtAddress(Bucket::class, $dataAddress + $i * $bucketSize); if ($bucket->val->u1->v->type !== 0) { $this->unStr($bucket, 'key'); $each($bucket->val); } } } - /** - * @param \FFI\CData $ht - */ private function serializeHash(object $ht, callable $each): void { + /** @var HashTableStruct $ht Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if (($ht->u->flags & self::HASH_FLAG_UNINITIALIZED) !== 0) { $this->writePtrField($ht, 'arData', 0); @@ -364,7 +537,7 @@ private function serializeHash(object $ht, callable $each): void if (($ht->u->flags & self::HASH_FLAG_PACKED) !== 0) { $zvalSize = Core::sizeOfType(zval::class); for ($i = 0; $i < $used; $i++) { - $zval = Core::pointerAtAddress('zval *', $dataAddress + $i * $zvalSize); + $zval = Core::pointerAtAddress(zval::class, $dataAddress + $i * $zvalSize); if ($zval->u1->v->type !== 0) { $each($zval); } @@ -374,7 +547,7 @@ private function serializeHash(object $ht, callable $each): void } $bucketSize = Core::sizeOfType(Bucket::class); for ($i = 0; $i < $used; $i++) { - $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); + $bucket = Core::pointerAtAddress(Bucket::class, $dataAddress + $i * $bucketSize); if ($bucket->val->u1->v->type !== 0) { $this->serStr($bucket, 'key'); $each($bucket->val); @@ -383,12 +556,10 @@ private function serializeHash(object $ht, callable $each): void } // --- zvals ------------------------------------------------------------- - /** - * @param \FFI\CData $zval - */ private function unserializeZval(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ switch ($zval->u1->v->type) { case self::IS_STRING: $stored = $this->ptrValue($zval->value, 'str'); @@ -400,7 +571,7 @@ private function unserializeZval(object $zval): void if (!$this->isUnserialized($this->ptrValue($zval->value, 'arr'))) { $arrAddress = $this->unPtr($zval->value, 'arr'); $this->unserializeHash( - Core::pointerAtAddress('zend_array *', $arrAddress), + Core::pointerAtAddress(HashTableStruct::class, $arrAddress), $this->unserializeZval(...), ); } @@ -416,12 +587,10 @@ private function unserializeZval(object $zval): void break; } } - /** - * @param \FFI\CData $zval - */ private function serializeZval(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ switch ($zval->u1->v->type) { case self::IS_STRING: if (!$this->isSerialized($this->ptrValue($zval->value, 'str'))) { @@ -432,7 +601,7 @@ private function serializeZval(object $zval): void if (!$this->isSerialized($this->ptrValue($zval->value, 'arr'))) { $arrAddress = $this->serPtr($zval->value, 'arr'); $this->serializeHash( - Core::pointerAtAddress('zend_array *', $arrAddress), + Core::pointerAtAddress(HashTableStruct::class, $arrAddress), $this->serializeZval(...), ); } @@ -454,25 +623,29 @@ private function serializeZval(object $zval): void /** @param int $astAddress address of the zend_ast (already resolved) */ private function unserializeAst(int $astAddress): void { - $ast = Core::pointerAtAddress('zend_ast *', $astAddress); + // The node header (kind + attr) must fit before it is read + $this->requireSpan($astAddress, Core::sizeOfType(zend_ast::class), 'zend_ast node'); + $ast = Core::pointerAtAddress(zend_ast::class, $astAddress); $kind = $ast->kind; if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { - $this->unserializeZval(Core::pointerAtAddress('zend_ast_zval *', $astAddress)->val); + $this->unserializeZval(Core::pointerAtAddress(zend_ast_zval::class, $astAddress)->val); return; } if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { - $list = Core::pointerAtAddress('zend_ast_list *', $astAddress); + $list = Core::pointerAtAddress(zend_ast_list::class, $astAddress); $childBase = $astAddress + Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE; - $count = $list->children; + $count = $this->requireCount((int) $list->children, 'ast list children'); } else { $childBase = $astAddress + Core::sizeOfType(zend_ast::class) - PHP_INT_SIZE; $count = $kind >> self::ZEND_AST_CHILDREN_SHIFT; } + $this->requireSpan($childBase, $count * PHP_INT_SIZE, 'ast children slots'); for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $childBase + $i * PHP_INT_SIZE)); - $child = (int) $slot[0]; + $child = $this->readSlot($slot); if ($child !== 0 && !$this->isUnserialized($child)) { + $this->requireOffset($child, 'ast child'); $slot[0] = $this->base + $child; $this->unserializeAst($this->base + $child); } @@ -481,15 +654,15 @@ private function unserializeAst(int $astAddress): void private function serializeAst(int $astAddress): void { - $ast = Core::pointerAtAddress('zend_ast *', $astAddress); + $ast = Core::pointerAtAddress(zend_ast::class, $astAddress); $kind = $ast->kind; if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { - $this->serializeZval(Core::pointerAtAddress('zend_ast_zval *', $astAddress)->val); + $this->serializeZval(Core::pointerAtAddress(zend_ast_zval::class, $astAddress)->val); return; } if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { - $list = Core::pointerAtAddress('zend_ast_list *', $astAddress); + $list = Core::pointerAtAddress(zend_ast_list::class, $astAddress); $childBase = $astAddress + Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE; $count = $list->children; } else { @@ -498,7 +671,7 @@ private function serializeAst(int $astAddress): void } for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $childBase + $i * PHP_INT_SIZE)); - $child = (int) $slot[0]; + $child = $this->readSlot($slot); if ($child !== 0 && !$this->isSerialized($child)) { $slot[0] = $child - $this->base; $this->serializeAst($child); @@ -507,9 +680,6 @@ private function serializeAst(int $astAddress): void } // --- attributes -------------------------------------------------------- - /** - * @param \FFI\CData $owner - */ private function unserializeAttributes(object $owner, string $field): void { @@ -519,13 +689,10 @@ private function unserializeAttributes(object $owner, string $field): void } $htAddress = $this->unPtr($owner, $field); $this->unserializeHash( - Core::pointerAtAddress('HashTable *', $htAddress), + Core::pointerAtAddress(HashTableStruct::class, $htAddress), $this->unserializeAttribute(...), ); } - /** - * @param \FFI\CData $owner - */ private function serializeAttributes(object $owner, string $field): void { @@ -535,101 +702,123 @@ private function serializeAttributes(object $owner, string $field): void } $htAddress = $this->serPtr($owner, $field); $this->serializeHash( - Core::pointerAtAddress('HashTable *', $htAddress), + Core::pointerAtAddress(HashTableStruct::class, $htAddress), $this->serializeAttribute(...), ); } - /** - * @param \FFI\CData $zval - */ private function unserializeAttribute(object $zval): void { - $attr = Core::pointerAtAddress('zend_attribute *', $this->unPtr($zval->value, 'ptr')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $attr = Core::pointerAtAddress(zend_attribute::class, $this->unPtr($zval->value, 'ptr')); $this->unStr($attr, 'name'); $this->unStr($attr, 'lcname'); $argSize = Core::sizeOfType(zend_attribute_arg::class); $argBase = Core::addressOf($attr->args); - for ($i = 0; $i < $attr->argc; $i++) { - $arg = Core::pointerAtAddress('zend_attribute_arg *', $argBase + $i * $argSize); + $argc = $this->requireCount((int) $attr->argc, 'attribute argc'); + $this->requireSpan($argBase, $argc * $argSize, 'attribute args'); + for ($i = 0; $i < $argc; $i++) { + $arg = Core::pointerAtAddress(zend_attribute_arg::class, $argBase + $i * $argSize); $this->unStr($arg, 'name'); $this->unserializeZval($arg->value); } } - /** - * @param \FFI\CData $zval - */ private function serializeAttribute(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $address = $this->serPtr($zval->value, 'ptr'); - $attr = Core::pointerAtAddress('zend_attribute *', $address); + $attr = Core::pointerAtAddress(zend_attribute::class, $address); $this->serStr($attr, 'name'); $this->serStr($attr, 'lcname'); $argSize = Core::sizeOfType(zend_attribute_arg::class); $argBase = Core::addressOf($attr->args); for ($i = 0; $i < $attr->argc; $i++) { - $arg = Core::pointerAtAddress('zend_attribute_arg *', $argBase + $i * $argSize); + $arg = Core::pointerAtAddress(zend_attribute_arg::class, $argBase + $i * $argSize); $this->serStr($arg, 'name'); $this->serializeZval($arg->value); } } // --- types (zend_type name/list) --------------------------------------- + /** - * @param \FFI\CData $owner + * One zend_type in place - the ZEND_TYPE_HAS_LIST branch of + * zend_file_cache_unserialize_type relocates the zend_type_list pointer and + * recurses into every entry, so DNF sub-lists like (A&B)|C unfold naturally. + * + * @param zend_type $type a zend_type view (embedded field or list entry) */ - - private function unserializeType(object $owner, string $field): void + private function unserializeTypeStruct(object $type): void { - $typeMask = $owner->$field->type_mask; + /** @var zend_type $type Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { - throw OpCacheException::unsupportedPayload('intersection/union type-list relocation'); + $listAddress = $this->unPtr($type, 'ptr'); + $this->requireSpan($listAddress, Core::sizeOfType(zend_type_list::class), 'zend_type_list header'); + $list = Core::pointerAtAddress(zend_type_list::class, $listAddress); + $typeSize = Core::sizeOfType(zend_type::class); + // ZEND_TYPE_LIST_FOREACH: entries start at list->types (the flexible member) + $entryBase = $listAddress + Core::sizeOfType(zend_type_list::class) - $typeSize; + $numTypes = $this->requireCount((int) $list->num_types, 'type list num_types'); + $this->requireSpan($entryBase, $numTypes * $typeSize, 'type list entries'); + for ($i = 0; $i < $numTypes; $i++) { + $this->unserializeTypeStruct(Core::pointerAtAddress(zend_type::class, $entryBase + $i * $typeSize)); + } + + return; } if (($typeMask & self::TYPE_NAME_BIT) !== 0) { - $this->unStr($owner->$field, 'ptr'); + $this->unStr($type, 'ptr'); } } + /** - * @param \FFI\CData $owner + * Mirror of {@see unserializeTypeStruct} - zend_file_cache_serialize_type + * stores the list pointer as an offset but keeps walking the entries through + * the still-real address (its SERIALIZE_PTR/UNSERIALIZE_PTR pair). + * + * @param zend_type $type a zend_type view (embedded field or list entry) */ - - private function serializeType(object $owner, string $field): void + private function serializeTypeStruct(object $type): void { - $typeMask = $owner->$field->type_mask; + /** @var zend_type $type Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { - throw OpCacheException::unsupportedPayload('intersection/union type-list relocation'); + $listAddress = $this->serPtr($type, 'ptr'); + $list = Core::pointerAtAddress(zend_type_list::class, $listAddress); + $typeSize = Core::sizeOfType(zend_type::class); + $entryBase = $listAddress + Core::sizeOfType(zend_type_list::class) - $typeSize; + for ($i = 0; $i < $list->num_types; $i++) { + $this->serializeTypeStruct(Core::pointerAtAddress(zend_type::class, $entryBase + $i * $typeSize)); + } + + return; } if (($typeMask & self::TYPE_NAME_BIT) !== 0) { - $this->serStr($owner->$field, 'ptr'); + $this->serStr($type, 'ptr'); } } // --- op_array (the executable body) ------------------------------------ - /** - * @param \FFI\CData $zval - */ private function unserializeFunc(object $zval): void { - $func = Core::pointerAtAddress('zend_function *', $this->unPtr($zval->value, 'func')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $func = Core::pointerAtAddress(zend_function::class, $this->unPtr($zval->value, 'func')); $this->unserializeOpArray($func->op_array); } - /** - * @param \FFI\CData $zval - */ private function serializeFunc(object $zval): void { - $func = Core::pointerAtAddress('zend_function *', $this->serPtr($zval->value, 'func')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $func = Core::pointerAtAddress(zend_function::class, $this->serPtr($zval->value, 'func')); $this->serializeOpArray($func->op_array); } - /** - * @param \FFI\CData $opArray - */ private function unserializeOpArray(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ // ZEND_MAP_PTR / run-time cache normalization is skipped (never executed here) if ($this->isUnserialized($this->ptrValue($opArray, 'opcodes'))) { return; // shared method body already relocated @@ -656,24 +845,39 @@ private function unserializeOpArray(object $opArray): void if ($this->ptrValue($opArray, 'static_variables') !== 0) { $address = $this->unPtr($opArray, 'static_variables'); - $this->unserializeHash(Core::pointerAtAddress('zend_array *', $address), $this->unserializeZval(...)); + $this->unserializeHash(Core::pointerAtAddress(HashTableStruct::class, $address), $this->unserializeZval(...)); } if ($this->ptrValue($opArray, 'literals') !== 0) { $address = $this->unPtr($opArray, 'literals'); $zvalSize = Core::sizeOfType(zval::class); - for ($i = 0; $i < $opArray->last_literal; $i++) { - $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); + $count = $this->requireCount((int) $opArray->last_literal, 'op_array last_literal'); + $this->requireSpan($address, $count * $zvalSize, 'op_array literals'); + for ($i = 0; $i < $count; $i++) { + $this->unserializeZval(Core::pointerAtAddress(zval::class, $address + $i * $zvalSize)); } } - // opcodes: only the array pointer is relocated; per-opline operands and - // handlers are literal indexes/relative jumps on this platform and are - // preserved verbatim (see class docblock) + // opcodes: only the array pointer is relocated. Per-opline operands are + // preserved verbatim because every 64-bit build uses relative addressing: + // ZEND_USE_ABS_CONST_ADDR / ZEND_USE_ABS_JMP_ADDR are 1 only when + // SIZEOF_SIZE_T == 4 (zend_compile.h), so in these payloads IS_CONST + // operands are literal-table indexes and jump operands are opline-relative + // byte offsets - position-independent on linux and darwin alike. The + // absolute-address per-opline walk of zend_file_cache.c is a 32-bit-only + // shape, excluded with the 32-bit refusal (issue #119; + // OpcodeAddressingModelTest is the tripwire should a build ever diverge). $this->unPtr($opArray, 'opcodes'); $this->unPtr($opArray, 'scope'); $this->unserializeArgInfo($opArray); $this->unserializeVars($opArray); if ($opArray->num_dynamic_func_defs !== 0) { - throw OpCacheException::unsupportedPayload('dynamic function definitions (closures/arrow fns) relocation'); + // zend_op_array* array: relocate it, then recurse into each nested body + $defsAddress = $this->unPtr($opArray, 'dynamic_func_defs'); + $count = $this->requireCount((int) $opArray->num_dynamic_func_defs, 'num_dynamic_func_defs'); + $this->requireSpan($defsAddress, $count * PHP_INT_SIZE, 'dynamic_func_defs table'); + for ($i = 0; $i < $count; $i++) { + $defAddress = $this->unPtrAt($defsAddress + $i * PHP_INT_SIZE); + $this->unserializeOpArray(Core::pointerAtAddress(zend_op_array::class, $defAddress)); + } } $this->unStr($opArray, 'function_name'); $this->unStr($opArray, 'filename'); @@ -684,12 +888,10 @@ private function unserializeOpArray(object $opArray): void $this->unPtr($opArray, 'prototype'); $this->unPtr($opArray, 'prop_info'); } - /** - * @param \FFI\CData $opArray - */ private function serializeOpArray(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isSerialized($this->ptrValue($opArray, 'opcodes'))) { return; } @@ -719,20 +921,26 @@ private function serializeOpArray(object $opArray): void if ($this->ptrValue($opArray, 'static_variables') !== 0) { $address = $this->serPtr($opArray, 'static_variables'); - $this->serializeHash(Core::pointerAtAddress('zend_array *', $address), $this->serializeZval(...)); + $this->serializeHash(Core::pointerAtAddress(HashTableStruct::class, $address), $this->serializeZval(...)); } if ($this->ptrValue($opArray, 'literals') !== 0) { $address = $this->serPtr($opArray, 'literals'); $zvalSize = Core::sizeOfType(zval::class); for ($i = 0; $i < $opArray->last_literal; $i++) { - $this->serializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); + $this->serializeZval(Core::pointerAtAddress(zval::class, $address + $i * $zvalSize)); } } $this->serPtr($opArray, 'opcodes'); $this->serializeArgInfo($opArray); $this->serializeVars($opArray); if ($opArray->num_dynamic_func_defs !== 0) { - throw OpCacheException::unsupportedPayload('dynamic function definitions (closures/arrow fns) relocation'); + // Store offsets but keep walking through the still-real addresses, + // exactly like the C SERIALIZE_PTR/UNSERIALIZE_PTR pairs + $defsAddress = $this->serPtr($opArray, 'dynamic_func_defs'); + for ($i = 0; $i < $opArray->num_dynamic_func_defs; $i++) { + $defAddress = $this->serPtrAt($defsAddress + $i * PHP_INT_SIZE); + $this->serializeOpArray(Core::pointerAtAddress(zend_op_array::class, $defAddress)); + } } $this->serStr($opArray, 'function_name'); $this->serStr($opArray, 'filename'); @@ -747,11 +955,11 @@ private function serializeOpArray(object $opArray): void /** * @return array{int, int} [start index, end index) for the arg_info walk - * @param \FFI\CData $opArray */ private function argInfoBounds(object $opArray): array { - $count = (int) $opArray->num_args; + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $count = $this->requireCount((int) $opArray->num_args, 'op_array num_args'); $start = 0; if (($opArray->fn_flags & self::ZEND_ACC_HAS_RETURN_TYPE) !== 0) { $start = -1; @@ -762,32 +970,30 @@ private function argInfoBounds(object $opArray): array return [$start, $count]; } - /** - * @param \FFI\CData $opArray - */ private function unserializeArgInfo(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($opArray, 'arg_info') === 0) { return; } $address = $this->unPtr($opArray, 'arg_info'); $argInfoSize = Core::sizeOfType(zend_arg_info::class); [$start, $end] = $this->argInfoBounds($opArray); + // The array starts at arg_info[start] (start is -1 for a return type) + $this->requireSpan($address + $start * $argInfoSize, ($end - $start) * $argInfoSize, 'op_array arg_info'); for ($i = $start; $i < $end; $i++) { - $arg = Core::pointerAtAddress('zend_arg_info *', $address + $i * $argInfoSize); + $arg = Core::pointerAtAddress(zend_arg_info::class, $address + $i * $argInfoSize); if (!$this->isUnserialized($this->ptrValue($arg, 'name'))) { $this->unStr($arg, 'name'); } - $this->unserializeType($arg, 'type'); + $this->unserializeTypeStruct($arg->type); } } - /** - * @param \FFI\CData $opArray - */ private function serializeArgInfo(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($opArray, 'arg_info') === 0) { return; } @@ -795,41 +1001,40 @@ private function serializeArgInfo(object $opArray): void $argInfoSize = Core::sizeOfType(zend_arg_info::class); [$start, $end] = $this->argInfoBounds($opArray); for ($i = $start; $i < $end; $i++) { - $arg = Core::pointerAtAddress('zend_arg_info *', $address + $i * $argInfoSize); + $arg = Core::pointerAtAddress(zend_arg_info::class, $address + $i * $argInfoSize); if (!$this->isSerialized($this->ptrValue($arg, 'name'))) { $this->serStr($arg, 'name'); } - $this->serializeType($arg, 'type'); + $this->serializeTypeStruct($arg->type); } } - /** - * @param \FFI\CData $opArray - */ private function unserializeVars(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($opArray, 'vars') === 0) { return; } $address = $this->unPtr($opArray, 'vars'); - for ($i = 0; $i < $opArray->last_var; $i++) { + $count = $this->requireCount((int) $opArray->last_var, 'op_array last_var'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'op_array vars table'); + for ($i = 0; $i < $count; $i++) { $slot = Core::pointerAtAddress('zend_string **', $address + $i * PHP_INT_SIZE); $view = Core::cast('uintptr_t *', $slot); - if (!$this->isUnserialized((int) $view[0]) && (int) $view[0] !== 0) { - if (((int) $view[0] & 1) !== 0) { - $view[0] = $this->strSectionBase + ((int) $view[0] & ~1); + if (!$this->isUnserialized($this->readSlot($view)) && $this->readSlot($view) !== 0) { + $this->requireStringOffset($this->readSlot($view), 'op_array var name'); + if (($this->readSlot($view) & 1) !== 0) { + $view[0] = $this->strSectionBase + ($this->readSlot($view) & ~1); } else { - $view[0] = $this->base + (int) $view[0]; + $view[0] = $this->base + $this->readSlot($view); } } } } - /** - * @param \FFI\CData $opArray - */ private function serializeVars(object $opArray): void { + /** @var zend_op_array $opArray Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($opArray, 'vars') === 0) { return; } @@ -837,7 +1042,7 @@ private function serializeVars(object $opArray): void for ($i = 0; $i < $opArray->last_var; $i++) { $slot = Core::pointerAtAddress('zend_string **', $address + $i * PHP_INT_SIZE); $view = Core::cast('uintptr_t *', $slot); - $stored = (int) $view[0]; + $stored = $this->readSlot($view); if ($stored === 0 || $this->isSerialized($stored)) { continue; } @@ -850,13 +1055,11 @@ private function serializeVars(object $opArray): void } // --- classes ----------------------------------------------------------- - /** - * @param \FFI\CData $zval - */ private function unserializeClass(object $zval): void { - $ce = Core::pointerAtAddress('zend_class_entry *', $this->unPtr($zval->value, 'ce')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $ce = Core::pointerAtAddress(zend_class_entry::class, $this->unPtr($zval->value, 'ce')); $this->unStr($ce, 'name'); if ($this->ptrValue($ce, 'parent') !== 0) { if (($ce->ce_flags & self::ZEND_ACC_LINKED) === 0) { @@ -878,7 +1081,9 @@ private function unserializeClass(object $zval): void $this->unserializeClassNames($ce, 'interface_names', $ce->num_interfaces); } if ($ce->num_traits !== 0) { - throw OpCacheException::unsupportedPayload('trait-using class relocation'); + $this->unserializeClassNames($ce, 'trait_names', $ce->num_traits); + $this->unserializeTraitAliases($ce); + $this->unserializeTraitPrecedences($ce); } foreach (self::MAGIC_METHOD_FIELDS as $field) { $this->unPtr($ce, $field); @@ -886,13 +1091,11 @@ private function unserializeClass(object $zval): void $this->unserializeIteratorFuncs($ce); // MAP_PTR / default_object_handlers / get_iterator are execution-only (skipped) } - /** - * @param \FFI\CData $zval - */ private function serializeClass(object $zval): void { - $ce = Core::pointerAtAddress('zend_class_entry *', $this->serPtr($zval->value, 'ce')); + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $ce = Core::pointerAtAddress(zend_class_entry::class, $this->serPtr($zval->value, 'ce')); $this->serStr($ce, 'name'); if ($this->ptrValue($ce, 'parent') !== 0) { if (($ce->ce_flags & self::ZEND_ACC_LINKED) === 0) { @@ -914,7 +1117,9 @@ private function serializeClass(object $zval): void $this->serializeClassNames($ce, 'interface_names', $ce->num_interfaces); } if ($ce->num_traits !== 0) { - throw OpCacheException::unsupportedPayload('trait-using class relocation'); + $this->serializeClassNames($ce, 'trait_names', $ce->num_traits); + $this->serializeTraitAliases($ce); + $this->serializeTraitPrecedences($ce); } foreach (self::MAGIC_METHOD_FIELDS as $field) { $this->serPtr($ce, $field); @@ -927,109 +1132,183 @@ private function serializeClass(object $zval): void '__serialize', '__unserialize', '__isset', '__unset', '__tostring', '__callstatic', '__debugInfo', ]; - /** - * @param \FFI\CData $ce - */ private function unserializePropertyTable(object $ce, string $field, int $count): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, $field) === 0) { return; } $address = $this->unPtr($ce, $field); $zvalSize = Core::sizeOfType(zval::class); + $count = $this->requireCount($count, "class {$field} count"); + $this->requireSpan($address, $count * $zvalSize, "class {$field}"); for ($i = 0; $i < $count; $i++) { - $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); + $this->unserializeZval(Core::pointerAtAddress(zval::class, $address + $i * $zvalSize)); } } - /** - * @param \FFI\CData $ce - */ private function serializePropertyTable(object $ce, string $field, int $count): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, $field) === 0) { return; } $address = $this->serPtr($ce, $field); $zvalSize = Core::sizeOfType(zval::class); for ($i = 0; $i < $count; $i++) { - $this->serializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); + $this->serializeZval(Core::pointerAtAddress(zval::class, $address + $i * $zvalSize)); } } - /** - * @param \FFI\CData $ce - */ private function unserializePropInfoTable(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'properties_info_table') === 0) { return; } $address = $this->unPtr($ce, 'properties_info_table'); - for ($i = 0; $i < $ce->default_properties_count; $i++) { + $count = $this->requireCount((int) $ce->default_properties_count, 'default_properties_count'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'properties_info_table'); + for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); - if ((int) $slot[0] !== 0) { - $slot[0] = $this->base + (int) $slot[0]; + if ($this->readSlot($slot) !== 0) { + $this->requireOffset($this->readSlot($slot), 'properties_info_table entry'); + $slot[0] = $this->base + $this->readSlot($slot); } } } - /** - * @param \FFI\CData $ce - */ private function serializePropInfoTable(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'properties_info_table') === 0) { return; } $address = $this->serPtr($ce, 'properties_info_table'); for ($i = 0; $i < $ce->default_properties_count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); - $stored = (int) $slot[0]; + $stored = $this->readSlot($slot); if ($stored !== 0) { $slot[0] = $stored - $this->base; } } } - /** - * @param \FFI\CData $ce - */ private function unserializeClassNames(object $ce, string $field, int $count): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $address = $this->unPtr($ce, $field); $nameSize = Core::sizeOfType(zend_class_name::class); + $count = $this->requireCount($count, "class {$field} count"); + $this->requireSpan($address, $count * $nameSize, "class {$field}"); for ($i = 0; $i < $count; $i++) { - $name = Core::pointerAtAddress('zend_class_name *', $address + $i * $nameSize); + $name = Core::pointerAtAddress(zend_class_name::class, $address + $i * $nameSize); $this->unStr($name, 'name'); $this->unStr($name, 'lc_name'); } } - /** - * @param \FFI\CData $ce - */ private function serializeClassNames(object $ce, string $field, int $count): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ $address = $this->serPtr($ce, $field); $nameSize = Core::sizeOfType(zend_class_name::class); for ($i = 0; $i < $count; $i++) { - $name = Core::pointerAtAddress('zend_class_name *', $address + $i * $nameSize); + $name = Core::pointerAtAddress(zend_class_name::class, $address + $i * $nameSize); $this->serStr($name, 'name'); $this->serStr($name, 'lc_name'); } } - /** - * @param \FFI\CData $zval - */ + + // --- traits (the num_traits branch of zend_file_cache_(un)serialize_class) + + private function unserializeTraitAliases(object $ce): void + { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + if ($this->ptrValue($ce, 'trait_aliases') === 0) { + return; + } + // A NULL-terminated zend_trait_alias* array; each entry's strings follow + $slotAddress = $this->unPtr($ce, 'trait_aliases'); + // Bound the terminator scan: each slot read must stay inside the region + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_aliases array'); + while (($aliasAddress = $this->unPtrAt($slotAddress)) !== 0) { + $alias = Core::pointerAtAddress(zend_trait_alias::class, $aliasAddress); + $this->unStr($alias->trait_method, 'method_name'); + $this->unStr($alias->trait_method, 'class_name'); + $this->unStr($alias, 'alias'); + $slotAddress += PHP_INT_SIZE; + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_aliases array'); + } + } + + private function serializeTraitAliases(object $ce): void + { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + if ($this->ptrValue($ce, 'trait_aliases') === 0) { + return; + } + $slotAddress = $this->serPtr($ce, 'trait_aliases'); + while (($aliasAddress = $this->serPtrAt($slotAddress)) !== 0) { + $alias = Core::pointerAtAddress(zend_trait_alias::class, $aliasAddress); + $this->serStr($alias->trait_method, 'method_name'); + $this->serStr($alias->trait_method, 'class_name'); + $this->serStr($alias, 'alias'); + $slotAddress += PHP_INT_SIZE; + } + } + + private function unserializeTraitPrecedences(object $ce): void + { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + if ($this->ptrValue($ce, 'trait_precedences') === 0) { + return; + } + // A NULL-terminated zend_trait_precedence* array with inline exclude names + $slotAddress = $this->unPtr($ce, 'trait_precedences'); + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_precedences array'); + while (($precedenceAddress = $this->unPtrAt($slotAddress)) !== 0) { + $precedence = Core::pointerAtAddress(zend_trait_precedence::class, $precedenceAddress); + $this->unStr($precedence->trait_method, 'method_name'); + $this->unStr($precedence->trait_method, 'class_name'); + $excludeBase = Core::addressOf($precedence->exclude_class_names); + $excludes = $this->requireCount((int) $precedence->num_excludes, 'trait precedence num_excludes'); + $this->requireSpan($excludeBase, $excludes * PHP_INT_SIZE, 'trait precedence excludes'); + for ($j = 0; $j < $excludes; $j++) { + $this->unStrAt($excludeBase + $j * PHP_INT_SIZE); + } + $slotAddress += PHP_INT_SIZE; + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_precedences array'); + } + } + + private function serializeTraitPrecedences(object $ce): void + { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + if ($this->ptrValue($ce, 'trait_precedences') === 0) { + return; + } + $slotAddress = $this->serPtr($ce, 'trait_precedences'); + while (($precedenceAddress = $this->serPtrAt($slotAddress)) !== 0) { + $precedence = Core::pointerAtAddress(zend_trait_precedence::class, $precedenceAddress); + $this->serStr($precedence->trait_method, 'method_name'); + $this->serStr($precedence->trait_method, 'class_name'); + $excludeBase = Core::addressOf($precedence->exclude_class_names); + for ($j = 0; $j < $precedence->num_excludes; $j++) { + $this->serStrAt($excludeBase + $j * PHP_INT_SIZE); + } + $slotAddress += PHP_INT_SIZE; + } + } private function unserializePropInfo(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isUnserialized($this->ptrValue($zval->value, 'ptr'))) { return; } - $prop = Core::pointerAtAddress('zend_property_info *', $this->unPtr($zval->value, 'ptr')); + $prop = Core::pointerAtAddress(zend_property_info::class, $this->unPtr($zval->value, 'ptr')); if ($this->isUnserialized($this->ptrValue($prop, 'ce'))) { return; } @@ -1041,20 +1320,27 @@ private function unserializePropInfo(object $zval): void $this->unserializeAttributes($prop, 'attributes'); $this->unPtr($prop, 'prototype'); if ($this->ptrValue($prop, 'hooks') !== 0) { - throw OpCacheException::unsupportedPayload('property-hook relocation'); + // zend_function*[ZEND_PROPERTY_HOOK_COUNT]: relocate the array, then + // each non-NULL hook and its op_array (a shared body returns early) + $hooksAddress = $this->unPtr($prop, 'hooks'); + $this->requireSpan($hooksAddress, self::PROPERTY_HOOK_COUNT * PHP_INT_SIZE, 'property hooks array'); + for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { + $hookAddress = $this->unPtrAt($hooksAddress + $i * PHP_INT_SIZE); + if ($hookAddress !== 0) { + $this->unserializeOpArray(Core::pointerAtAddress(zend_function::class, $hookAddress)->op_array); + } + } } - $this->unserializeType($prop, 'type'); + $this->unserializeTypeStruct($prop->type); } - /** - * @param \FFI\CData $zval - */ private function serializePropInfo(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isSerialized($this->ptrValue($zval->value, 'ptr'))) { return; } - $prop = Core::pointerAtAddress('zend_property_info *', $this->serPtr($zval->value, 'ptr')); + $prop = Core::pointerAtAddress(zend_property_info::class, $this->serPtr($zval->value, 'ptr')); if ($this->isSerialized($this->ptrValue($prop, 'ce'))) { return; } @@ -1066,20 +1352,26 @@ private function serializePropInfo(object $zval): void $this->serializeAttributes($prop, 'attributes'); $this->serPtr($prop, 'prototype'); if ($this->ptrValue($prop, 'hooks') !== 0) { - throw OpCacheException::unsupportedPayload('property-hook relocation'); + // Offsets are stored while the walk continues through the still-real + // addresses, mirroring the C SERIALIZE_PTR/UNSERIALIZE_PTR pairs + $hooksAddress = $this->serPtr($prop, 'hooks'); + for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { + $hookAddress = $this->serPtrAt($hooksAddress + $i * PHP_INT_SIZE); + if ($hookAddress !== 0) { + $this->serializeOpArray(Core::pointerAtAddress(zend_function::class, $hookAddress)->op_array); + } + } } - $this->serializeType($prop, 'type'); + $this->serializeTypeStruct($prop->type); } - /** - * @param \FFI\CData $zval - */ private function unserializeClassConstant(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isUnserialized($this->ptrValue($zval->value, 'ptr'))) { return; } - $constant = Core::pointerAtAddress('zend_class_constant *', $this->unPtr($zval->value, 'ptr')); + $constant = Core::pointerAtAddress(zend_class_constant::class, $this->unPtr($zval->value, 'ptr')); if ($this->isUnserialized($this->ptrValue($constant, 'ce'))) { return; } @@ -1089,18 +1381,16 @@ private function unserializeClassConstant(object $zval): void $this->unStr($constant, 'doc_comment'); } $this->unserializeAttributes($constant, 'attributes'); - $this->unserializeType($constant, 'type'); + $this->unserializeTypeStruct($constant->type); } - /** - * @param \FFI\CData $zval - */ private function serializeClassConstant(object $zval): void { + /** @var zval $zval Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->isSerialized($this->ptrValue($zval->value, 'ptr'))) { return; } - $constant = Core::pointerAtAddress('zend_class_constant *', $this->serPtr($zval->value, 'ptr')); + $constant = Core::pointerAtAddress(zend_class_constant::class, $this->serPtr($zval->value, 'ptr')); if ($this->isSerialized($this->ptrValue($constant, 'ce'))) { return; } @@ -1110,104 +1400,135 @@ private function serializeClassConstant(object $zval): void $this->serStr($constant, 'doc_comment'); } $this->serializeAttributes($constant, 'attributes'); - $this->serializeType($constant, 'type'); + $this->serializeTypeStruct($constant->type); } + /** - * @param \FFI\CData $ce + * zf_* field order matches the C walk (zend_file_cache.c), not the struct layout. + * Only linked classes carry these structs - a plain compile stores classes + * unlinked with both pointers NULL - but payloads from other producers (e.g. + * preload-era images) may hold them, and the walk must be faithful when they do. */ + private const ITERATOR_FUNC_FIELDS = ['zf_new_iterator', 'zf_rewind', 'zf_valid', 'zf_key', 'zf_current', 'zf_next']; + private const ARRAYACCESS_FUNC_FIELDS = ['zf_offsetget', 'zf_offsetexists', 'zf_offsetset', 'zf_offsetunset']; + /** + * The get_iterator <-> HOOKED_ITERATOR_PLACEHOLDER swap the C load path performs + * is deliberately NOT mirrored: the image is never executed in this process, so + * the placeholder is preserved verbatim like every other execution-only field + * and the written file keeps the exact bytes the engine expects. + * + */ private function unserializeIteratorFuncs(object $ce): void { + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($ce, 'iterator_funcs_ptr') !== 0) { - throw OpCacheException::unsupportedPayload('iterator-aware class relocation'); + $address = $this->unPtr($ce, 'iterator_funcs_ptr'); + $funcs = Core::pointerAtAddress(zend_class_iterator_funcs::class, $address); + foreach (self::ITERATOR_FUNC_FIELDS as $field) { + $this->unPtr($funcs, $field); + } } if ($this->ptrValue($ce, 'arrayaccess_funcs_ptr') !== 0) { - throw OpCacheException::unsupportedPayload('ArrayAccess class relocation'); + $address = $this->unPtr($ce, 'arrayaccess_funcs_ptr'); + $funcs = Core::pointerAtAddress(zend_class_arrayaccess_funcs::class, $address); + foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { + $this->unPtr($funcs, $field); + } } } - /** - * @param \FFI\CData $ce - */ private function serializeIteratorFuncs(object $ce): void { - if ($this->ptrValue($ce, 'iterator_funcs_ptr') !== 0) { - throw OpCacheException::unsupportedPayload('iterator-aware class relocation'); + /** @var zend_class_entry $ce Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + // The C serialize converts the zf_* members through the still-real struct + // pointer first and the struct pointer itself last; mirrored exactly + $iteratorAddress = $this->ptrValue($ce, 'iterator_funcs_ptr'); + if ($iteratorAddress !== 0) { + $funcs = Core::pointerAtAddress(zend_class_iterator_funcs::class, $iteratorAddress); + foreach (self::ITERATOR_FUNC_FIELDS as $field) { + $this->serPtr($funcs, $field); + } + $this->serPtr($ce, 'iterator_funcs_ptr'); } - if ($this->ptrValue($ce, 'arrayaccess_funcs_ptr') !== 0) { - throw OpCacheException::unsupportedPayload('ArrayAccess class relocation'); + $arrayAccessAddress = $this->ptrValue($ce, 'arrayaccess_funcs_ptr'); + if ($arrayAccessAddress !== 0) { + $funcs = Core::pointerAtAddress(zend_class_arrayaccess_funcs::class, $arrayAccessAddress); + foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { + $this->serPtr($funcs, $field); + } + $this->serPtr($ce, 'arrayaccess_funcs_ptr'); } } // --- warnings / early bindings ----------------------------------------- - /** - * @param \FFI\CData $script - */ private function unserializeWarnings(object $script): void { + /** @var zend_persistent_script $script Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($script, 'warnings') === 0) { return; } $address = $this->unPtr($script, 'warnings'); - for ($i = 0; $i < $script->num_warnings; $i++) { - $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); - $slot[0] = $this->base + (int) $slot[0]; - $warning = Core::pointerAtAddress('zend_error_info *', (int) $slot[0]); + $count = $this->requireCount((int) $script->num_warnings, 'num_warnings'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'warnings table'); + for ($i = 0; $i < $count; $i++) { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); + $stored = $this->readSlot($slot); + $this->requireOffset($stored, 'warning entry'); + $resolved = $this->base + $stored; + $slot[0] = $resolved; + $warning = Core::pointerAtAddress(zend_error_info::class, $resolved); $this->unStr($warning, 'filename'); $this->unStr($warning, 'message'); } } - /** - * @param \FFI\CData $script - */ private function serializeWarnings(object $script): void { + /** @var zend_persistent_script $script Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($script, 'warnings') === 0) { return; } $address = $this->serPtr($script, 'warnings'); for ($i = 0; $i < $script->num_warnings; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); - $warnAddr = (int) $slot[0]; + $warnAddr = $this->readSlot($slot); $slot[0] = $warnAddr - $this->base; - $warning = Core::pointerAtAddress('zend_error_info *', $warnAddr); + $warning = Core::pointerAtAddress(zend_error_info::class, $warnAddr); $this->serStr($warning, 'filename'); $this->serStr($warning, 'message'); } } - /** - * @param \FFI\CData $script - */ private function unserializeEarlyBindings(object $script): void { + /** @var zend_persistent_script $script Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($script, 'early_bindings') === 0) { return; } $address = $this->unPtr($script, 'early_bindings'); $bindingSize = Core::sizeOfType(zend_early_binding::class); - for ($i = 0; $i < $script->num_early_bindings; $i++) { - $binding = Core::pointerAtAddress('zend_early_binding *', $address + $i * $bindingSize); + $count = $this->requireCount((int) $script->num_early_bindings, 'num_early_bindings'); + $this->requireSpan($address, $count * $bindingSize, 'early_bindings table'); + for ($i = 0; $i < $count; $i++) { + $binding = Core::pointerAtAddress(zend_early_binding::class, $address + $i * $bindingSize); $this->unStr($binding, 'lcname'); $this->unStr($binding, 'rtd_key'); $this->unStr($binding, 'lc_parent_name'); } } - /** - * @param \FFI\CData $script - */ private function serializeEarlyBindings(object $script): void { + /** @var zend_persistent_script $script Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ if ($this->ptrValue($script, 'early_bindings') === 0) { return; } $address = $this->serPtr($script, 'early_bindings'); $bindingSize = Core::sizeOfType(zend_early_binding::class); for ($i = 0; $i < $script->num_early_bindings; $i++) { - $binding = Core::pointerAtAddress('zend_early_binding *', $address + $i * $bindingSize); + $binding = Core::pointerAtAddress(zend_early_binding::class, $address + $i * $bindingSize); $this->serStr($binding, 'lcname'); $this->serStr($binding, 'rtd_key'); $this->serStr($binding, 'lc_parent_name'); diff --git a/src/OpCache/ReflectionOpcacheFile.php b/src/OpCache/ReflectionOpcacheFile.php index cee2c121..1ce61540 100644 --- a/src/OpCache/ReflectionOpcacheFile.php +++ b/src/OpCache/ReflectionOpcacheFile.php @@ -14,8 +14,13 @@ namespace ZEngine\OpCache; use FFI; -use FFI\CData; use ZEngine\Core; +use ZEngine\Generated\Bucket; +use ZEngine\Generated\HashTable as HashTableStruct; +use ZEngine\Generated\zend_class_entry; +use ZEngine\Generated\zend_op_array; +use ZEngine\Generated\zend_persistent_script; +use ZEngine\Generated\zend_string; use ZEngine\Reflection\ReflectionClass; use ZEngine\Reflection\ReflectionFunction; use ZEngine\Type\HashTable; @@ -36,17 +41,65 @@ */ final class ReflectionOpcacheFile { + /** Set once a mutation outgrew the original buffer; routes save() to ScriptSerializer */ + private bool $graphGrown = false; + + /** @var list donor images whose units this image now references */ + private array $donors = []; + /** - * @param \FFI\CData $script + * @param \FFI\CData|zend_persistent_script $script Relocated zend_persistent_script inside the image buffer + * @param object|null $imageOwner Owner of the relocated buffer (the PayloadRelocator): + * retained so that holding this view alone provably keeps + * the buffer - which every wrapper this view hands out + * points into - alive (see CacheImageSync, whose swapped-in + * bodies keep executing out of that buffer) */ - public function __construct(private readonly object $script) {} + public function __construct( + /** @var zend_persistent_script Typed view of the relocated persistent script this handle wraps */ + private readonly object $script, + // @phpstan-ignore property.onlyWritten (lifetime pin: held, never read) + private readonly ?object $imageOwner = null, + ) {} + + /** + * The relocated zend_persistent_script this handle wraps + * + * @internal core-layer escape hatch for BinaryCacheFile/ScriptSerializer + * @return zend_persistent_script + */ + public function getRawScript(): object + { + return $this->script; + } + + /** Whether a mutation grew the graph beyond the original buffer */ + public function isGraphGrown(): bool + { + return $this->graphGrown; + } + + /** + * The donor images this image references after grafting; their buffers must + * stay materialized until save() re-emits the graph into one fresh region + * + * @return list + */ + public function donorImages(): array + { + return $this->donors; + } /** * The cached script's source path (parity with ReflectionClass::getFileName()) */ public function getFileName(): string { - return StringEntry::fromCData($this->script->script->filename)->getStringValue(); + // A persistent script always carries a filename block + $filename = $this->script->script->filename; + \assert($filename !== null); + + return StringEntry::fromCData($filename)->getStringValue(); } /** @@ -54,7 +107,7 @@ public function getFileName(): string */ public function getScriptFunction(): ReflectionFunction { - $function = Core::cast('zend_function *', FFI::addr($this->script->script->main_op_array)); + $function = Core::cast('zend_function *', Core::addr($this->script->script->main_op_array)); return ReflectionFunction::fromCData($function); } @@ -64,7 +117,7 @@ public function getScriptFunction(): ReflectionFunction */ public function functionTable(): HashTable { - return HashTable::fromCData(FFI::addr($this->script->script->function_table)); + return HashTable::fromCData(Core::addr($this->script->script->function_table)); } /** @@ -72,7 +125,66 @@ public function functionTable(): HashTable */ public function classTable(): HashTable { - return HashTable::fromCData(FFI::addr($this->script->script->class_table)); + return HashTable::fromCData(Core::addr($this->script->script->class_table)); + } + + /** + * Grafts a function from another cache image into this script (issue #117). + * + * The donor must come from a cache binary compiled by a real opcache child, + * so its op_array is already in file form (handler indexes, literal-index + * operands) - the graph serializer copies such units verbatim. The donor + * image is referenced, not copied, until {@see BinaryCacheFile::save()} + * re-emits the whole graph through {@see ScriptSerializer}. + */ + public function addFunctionFrom(self $donor, string $functionName): void + { + $key = strtolower($functionName); + $entry = self::findKeyedEntry($donor->script->script->function_table, $key); + if ($entry === null) { + throw OpCacheException::graftEntryNotFound('function', $functionName); + } + [$keyAddress, $functionAddress] = $entry; + $this->insertPtrEntry($this->script->script->function_table, $keyAddress, $functionAddress); + $this->donors[] = $donor; + $this->graphGrown = true; + } + + /** + * Grafts a method from a donor image's class into a class of this script. + * + * The donor method's scope is re-pointed at the target class (the donor + * image is mutated - it is tied to this image from here on), exactly what + * zend_persist expects of a method hanging off that class's function table. + */ + public function addMethodFrom(self $donor, string $donorClassName, string $methodName, string $targetClassName): void + { + $donorClass = $donor->findClassByName($donorClassName); + if ($donorClass === null) { + throw OpCacheException::graftEntryNotFound('class', $donorClassName); + } + $targetClass = $this->findClassByName($targetClassName); + if ($targetClass === null) { + throw OpCacheException::graftEntryNotFound('class', $targetClassName); + } + $entry = self::findKeyedEntry($donorClass->function_table, strtolower($methodName)); + if ($entry === null) { + throw OpCacheException::graftEntryNotFound('method', "{$donorClassName}::{$methodName}"); + } + [$keyAddress, $methodAddress] = $entry; + + // Re-point the method's scope at the adopting class + $method = Core::pointerAtAddress(zend_op_array::class, $methodAddress); + // A grafted method op_array always carries its donor-class scope slot + \assert($method->scope !== null); + // FFI::addr must stay inline on the pointer-field access to yield the + // scope SLOT address (a by-value hop would address a pointer copy). + // @phpstan-ignore argument.type (FFI::addr of a zend_class_entry* pointer field) + Core::cast('uintptr_t *', FFI::addr($method->scope))[0] = Core::addressOf($targetClass); + + $this->insertPtrEntry($targetClass->function_table, $keyAddress, $methodAddress); + $this->donors[] = $donor; + $this->graphGrown = true; } /** @@ -112,4 +224,163 @@ public function getClasses(): array return $classes; } + + // --- graft plumbing (issue #117) ---------------------------------------- + + /** + * Finds a class entry by its own name, case-insensitively; class-table + * bucket keys can be opcache rtd keys, so match on ce->name instead. + * + * @return zend_class_entry|null a zend_class_entry* into the image + */ + private function findClassByName(string $className): ?object + { + $ht = $this->script->script->class_table; + $bucketSize = Core::sizeOfType(Bucket::class); + // A populated class table always carries a bucket data block + \assert($ht->arData !== null); + $dataAddress = Core::addressOf($ht->arData); + for ($i = 0; $i < $ht->nNumUsed; $i++) { + $bucket = Core::pointerAtAddress(Bucket::class, $dataAddress + $i * $bucketSize); + if ($bucket->val->u1->v->type === 0) { + continue; + } + $classEntry = Core::pointerAtAddress( + zend_class_entry::class, + // IS_PTR bucket: the stored class-entry pointer lives in the value union's long slot + $bucket->val->value->lval, + ); + // Every compiled class entry carries its own name block + \assert($classEntry->name !== null); + $name = StringEntry::fromCData($classEntry->name)->getStringValue(); + if (strcasecmp($name, $className) === 0) { + return $classEntry; + } + } + + return null; + } + + /** + * Finds a bucket by exact key in a keyed image table. + * + * @param HashTableStruct $ht HashTable view + * @return array{int, int}|null [key zend_string address, value pointer address] + */ + private static function findKeyedEntry(object $ht, string $key): ?array + { + if (($ht->u->flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0) { + return null; + } + $bucketSize = Core::sizeOfType(Bucket::class); + // An initialized (non-uninitialized) table always carries a bucket data block + \assert($ht->arData !== null); + $dataAddress = Core::addressOf($ht->arData); + for ($i = 0; $i < $ht->nNumUsed; $i++) { + $bucket = Core::pointerAtAddress(Bucket::class, $dataAddress + $i * $bucketSize); + if ($bucket->val->u1->v->type === 0 || $bucket->key === null) { + continue; + } + if (StringEntry::fromCData($bucket->key)->getStringValue() === $key) { + return [ + Core::addressOf($bucket->key), + // IS_PTR bucket: the stored pointer lives in the value union's long slot + $bucket->val->value->lval, + ]; + } + } + + return null; + } + + /** + * Inserts an IS_PTR entry into an image hashtable, regrowing its data block + * outside the buffer (issue #117). Image tables were laid out by + * zend_hash_persist - their data is NOT an emalloc'd block, so the engine's + * zend_hash_add must never touch them; this reimplements the insert the way + * the persisted format expects it (hash slots ahead of arData, bucket-index + * chains via Z_NEXT, HT_SIZE_TO_MASK = -(2 * nTableSize)). + * + * @param HashTableStruct $ht HashTable view (embedded in the image) + */ + private function insertPtrEntry(object $ht, int $keyAddress, int $valueAddress): void + { + $flags = $ht->u->flags; + if (($flags & Core::engineConstant('HASH_FLAG_PACKED')) !== 0) { + throw OpCacheException::unsupportedPayload('grafting into a packed hashtable'); + } + $key = Core::pointerAtAddress(zend_string::class, $keyAddress); + $hash = $key->h; + if ($hash === 0) { + throw OpCacheException::unsupportedPayload('graft key string carries no precomputed hash'); + } + + $bucketSize = Core::sizeOfType(Bucket::class); + $uninitialized = ($flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0; + // An initialized image table always points its data block at real buckets + \assert($uninitialized || $ht->arData !== null); + $used = $uninitialized ? 0 : $ht->nNumUsed; + $tableSize = $uninitialized ? 8 : $ht->nTableSize; + $oldData = $uninitialized ? 0 : Core::addressOf($ht->arData); + + if (!$uninitialized && self::findKeyedEntry($ht, StringEntry::fromCData($key)->getStringValue()) !== null) { + throw OpCacheException::duplicateHashTableKey(StringEntry::fromCData($key)->getStringValue()); + } + + $newUsed = $used + 1; + while ($newUsed > $tableSize) { + $tableSize <<= 1; + } + // HT_SIZE_TO_MASK(nTableSize) = (uint32)(-(nTableSize + nTableSize)) + $newMask = (0x100000000 - 2 * $tableSize) & 0xFFFFFFFF; + $hashBytes = 2 * $tableSize * 4; + $capacity = $hashBytes + $tableSize * $bucketSize; + $block = Core::new("char[{$capacity}]", false); + $blockBase = Core::addressOf(Core::addr($block)); + $newData = $blockBase + $hashBytes; + + if ($used > 0) { + Core::memcpy( + Core::cast('char *', Core::pointerAtAddress('void *', $newData)), + Core::cast('char *', Core::pointerAtAddress('void *', $oldData)), + $used * $bucketSize, + ); + } + + // The appended bucket: an IS_PTR zval, hash and key + $bucket = Core::pointerAtAddress(Bucket::class, $newData + $used * $bucketSize); + $bucket->val->u1->type_info = Core::engineConstant('IS_PTR'); + Core::cast('uintptr_t *', Core::addr($bucket->val->value))[0] = $valueAddress; + $bucket->h = $hash; + $bucket->key = $key; + + // HT_HASH_RESET + full rehash (bucket-index chains, like zend_hash_persist) + for ($i = 0; $i < 2 * $tableSize; $i++) { + Core::cast('uint32_t *', Core::pointerAtAddress('void *', $blockBase + $i * 4))[0] = 0xFFFFFFFF; // HT_INVALID_IDX + } + for ($idx = 0; $idx < $newUsed; $idx++) { + $entry = Core::pointerAtAddress(Bucket::class, $newData + $idx * $bucketSize); + if ($entry->val->u1->v->type === 0) { + continue; + } + $nIndex = ($entry->h | $newMask) & 0xFFFFFFFF; + $slot = $nIndex - 0x100000000; // (int32_t)nIndex, always negative + $slotAddr = $newData + $slot * 4; + // HT_HASH slots are uint32_t; the deref reads as a PHP int (Z_NEXT chain head) + $chainHead = Core::cast('uint32_t *', Core::pointerAtAddress('void *', $slotAddr))[0]; + \assert(\is_int($chainHead)); + $entry->val->u2->next = $chainHead; + Core::cast('uint32_t *', Core::pointerAtAddress('void *', $slotAddr))[0] = $idx; + } + + $ht->arData = Core::pointerAtAddress(Bucket::class, $newData); + $ht->nNumUsed = $newUsed; + $ht->nNumOfElements = ($uninitialized ? 0 : $ht->nNumOfElements) + 1; + $ht->nTableSize = $tableSize; + $ht->nTableMask = $newMask; + $ht->nInternalPointer = 0; + if ($uninitialized) { + $ht->u->flags = $flags & ~Core::engineConstant('HASH_FLAG_UNINITIALIZED'); + } + } } diff --git a/src/OpCache/ScriptSerializer.php b/src/OpCache/ScriptSerializer.php new file mode 100644 index 00000000..a9dceb8f --- /dev/null +++ b/src/OpCache/ScriptSerializer.php @@ -0,0 +1,1154 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use FFI; +use FFI\CData; +use ZEngine\Core; +use ZEngine\Generated\Bucket; +use ZEngine\Generated\HashTable as HashTableStruct; +use ZEngine\Generated\zend_arg_info; +use ZEngine\Generated\zend_ast; +use ZEngine\Generated\zend_ast_list; +use ZEngine\Generated\zend_ast_ref; +use ZEngine\Generated\zend_attribute; +use ZEngine\Generated\zend_attribute_arg; +use ZEngine\Generated\zend_class_arrayaccess_funcs; +use ZEngine\Generated\zend_class_constant; +use ZEngine\Generated\zend_class_entry; +use ZEngine\Generated\zend_class_iterator_funcs; +use ZEngine\Generated\zend_class_name; +use ZEngine\Generated\zend_early_binding; +use ZEngine\Generated\zend_error_info; +use ZEngine\Generated\zend_live_range; +use ZEngine\Generated\zend_op_array; +use ZEngine\Generated\zend_persistent_script; +use ZEngine\Generated\zend_property_info; +use ZEngine\Generated\zend_string; +use ZEngine\Generated\zend_trait_alias; +use ZEngine\Generated\zend_trait_precedence; +use ZEngine\Generated\zend_try_catch_element; +use ZEngine\Generated\zend_type; +use ZEngine\Generated\zend_type_list; +use ZEngine\Generated\zval; + +/** + * The from-scratch persist-from-graph serializer (issue #117): a two-pass port + * of zend_persist_calc -> zend_persist fused with the offset-encoding stage of + * zend_file_cache_serialize, for graphs that grew beyond the original buffer + * (added functions/methods, regrown hashtables, replaced sub-arrays). + * + * Pass 1 walks the (possibly mutated) live graph rooted at the + * zend_persistent_script, deduplicating every reachable allocation unit through + * an xlat table (the port of zend_shared_alloc_get/register_xlat_entry) and + * computing the total ZEND_MM_ALIGNED size, exactly like zend_persist_calc. + * Pass 2 emits a fresh contiguous buffer: every unit is copied byte-verbatim to + * its assigned offset and every pointer field is rewritten to the copy's new + * address - producing a valid RELOCATED image, whose conversion to the on-disk + * offset form is then delegated to the proven {@see PayloadRelocator} + * (serialize = derelocate), so the offset/interning encoding has exactly one + * implementation. + * + * Two deliberate simplifications against zend_persist.c, both valid per the + * file-cache format: + * + * - every zend_string is copied into the mem region (the compile child in + * file_cache_only mode does the same: nothing is accel-interned there, so + * zend_accel_store_interned_string region-copies every string). The emitted + * image therefore carries an empty interned-string section, and any string + * whose source lacks the interned GC bits gets them stamped on the copy - + * the port of zend_set_str_gc_flags' file_cache_only branch; + * - sparse hashtables are copied as-is instead of compacted (zend_hash_persist + * compacts as an optimization only; mask/index invariants are preserved + * either way). + * + * Inputs must be persisted images: the walkers copy payload bytes verbatim, so + * every op_array reachable from the graph must already be in file form (opline + * handlers as table indexes, IS_CONST operands as literal indexes) - which is + * true for anything that came out of a cache binary, including grafts pulled + * from a donor binary compiled by a real opcache child. Freshly in-process + * compiled op_arrays are NOT accepted implicitly; grafting goes through + * {@see ReflectionOpcacheFile::addFunctionFrom()} / addMethodFrom(), which only + * take donors from other cache binaries. + * + * @internal core-layer machinery, constructed by BinaryCacheFile::save() + */ +final class ScriptSerializer +{ + private const int IS_STRING = 6; + private const int IS_ARRAY = 7; + private const int IS_CONSTANT_AST = 11; + private const int IS_INDIRECT = 12; + + private const int ZEND_AST_ZVAL = 64; + private const int ZEND_AST_CONSTANT = 65; + private const int ZEND_AST_IS_LIST_SHIFT = 7; + private const int ZEND_AST_CHILDREN_SHIFT = 8; + + /** zend_type bit layout (zend_types.h) - list/name discriminators */ + private const int TYPE_LIST_BIT = 4194304; // _ZEND_TYPE_LIST_BIT + private const int TYPE_NAME_BIT = 16777216; // _ZEND_TYPE_NAME_BIT + + /** ZEND_PROPERTY_HOOK_COUNT (zend_property_hooks.h) - get + set slots */ + private const int PROPERTY_HOOK_COUNT = 2; + + private const MAGIC_METHOD_FIELDS = [ + 'constructor', 'destructor', 'clone', '__get', '__set', '__call', + '__serialize', '__unserialize', '__isset', '__unset', '__tostring', + '__callstatic', '__debugInfo', + ]; + private const ITERATOR_FUNC_FIELDS = ['zf_new_iterator', 'zf_rewind', 'zf_valid', 'zf_key', 'zf_current', 'zf_next']; + private const ARRAYACCESS_FUNC_FIELDS = ['zf_offsetget', 'zf_offsetexists', 'zf_offsetset', 'zf_offsetunset']; + + /** 1 = measure (zend_persist_calc), 2 = emit (zend_persist + file-cache encode) */ + private int $phase = 1; + + /** @var array source unit address => offset in the new region (the xlat table) */ + private array $xlat = []; + /** @var list sorted source unit start addresses (interior-pointer resolution) */ + private array $unitStarts = []; + /** @var array source unit start => byte size */ + private array $unitSizes = []; + /** @var array source unit address => copied guard (pass 2) */ + private array $copied = []; + /** + * Pointer fields whose target unit may not be translated yet at emit time + * (prototypes, scopes, prop_info back-references, magic-method slots ...): + * resolved against the finished xlat after the walk, like the late + * zend_shared_alloc_get_xlat_entry lookups in zend_persist.c. + * + * @var list [slot address in the copy, source target, description] + */ + private array $deferred = []; + + private int $total = 0; + /** @var CData|null the emitted buffer (kept alive by the instance) */ + private ?CData $out = null; + private int $newBase = 0; + + private readonly int $zendStringHeaderSize; + + /** + * @param CData|zend_persistent_script $script the relocated zend_persistent_script* of the live image + */ + public function __construct(private readonly object $script) + { + if (!PayloadRelocator::isSupported()) { + throw OpCacheException::unsupportedPayload('the graph serializer supports 64-bit POSIX builds only'); + } + // _ZSTR_HEADER_SIZE = XtOffsetOf(zend_string, val): the flexible val[1] + // member starts at the last 8-byte slot of the (padded) struct + $this->zendStringHeaderSize = Core::sizeOfType(zend_string::class) - PHP_INT_SIZE; + } + + /** + * Emits a fresh payload (mem region + empty string section) from the graph. + * The source image is never written to, so the live view stays valid and + * serialize() can be called again after further mutations. + */ + public function serialize(): string + { + $scriptAddress = Core::addressOf($this->script); + + $this->phase = 1; + $this->xlat = []; + $this->unitSizes = []; + $this->total = 0; + $this->persistScript($scriptAddress); + + $this->unitStarts = array_keys($this->unitSizes); + sort($this->unitStarts); + + $this->phase = 2; + $this->copied = []; + $this->deferred = []; + $this->out = Core::new("char[{$this->total}]", false); + $this->newBase = Core::addressOf(Core::addr($this->out)); + $this->persistScript($scriptAddress); + $this->resolveDeferred(); + + // The emit buffer was just allocated above and is never cleared here + \assert($this->out !== null); + // The emitted region is a valid relocated image; the on-disk offset + // encoding is the relocator's serialize - byte-tested machinery + $meta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $this->total, + strSize: 0, + scriptOffset: $this->xlat[$scriptAddress], + timestamp: 0, + checksum: 0, + ); + $relocator = new PayloadRelocator($this->out, $meta); + + return $relocator->derelocate(); + } + + /** Size of the emitted mem region; only meaningful after serialize() */ + public function memSize(): int + { + return $this->total; + } + + /** Offset of the zend_persistent_script inside the emitted region */ + public function scriptOffset(): int + { + return $this->xlat[Core::addressOf($this->script)] ?? 0; + } + + // --- unit / pointer primitives ------------------------------------------ + + /** + * Registers (pass 1) or copies (pass 2) one allocation unit. + * + * @return array{int, bool} [address of the copy (0 in pass 1), first visit?] + */ + private function unit(int $source, int $size): array + { + if ($this->phase === 1) { + if (isset($this->xlat[$source])) { + return [0, false]; + } + $this->xlat[$source] = $this->total; + $this->unitSizes[$source] = $size; + $this->total += Core::getAlignedSize($size); + + return [0, true]; + } + if (!isset($this->xlat[$source])) { + throw OpCacheException::unresolvedGraphReference(sprintf('unit 0x%x reached only in the emit pass', $source)); + } + $new = $this->newBase + $this->xlat[$source]; + if (isset($this->copied[$source])) { + return [$new, false]; + } + $this->copied[$source] = true; + FFI::memcpy( + Core::cast('char *', Core::pointerAtAddress('void *', $new)), + Core::cast('char *', Core::pointerAtAddress('void *', $source)), + // max(...,0) only states the non-negative unit size to the analyser + max($size, 0), + ); + + return [$new, true]; + } + + /** Translates a source address to its copy, resolving interior pointers */ + private function mapAddress(int $source): int + { + if (isset($this->xlat[$source])) { + return $this->newBase + $this->xlat[$source]; + } + // Binary search for the unit containing the address + $low = 0; + $high = \count($this->unitStarts) - 1; + while ($low <= $high) { + $mid = ($low + $high) >> 1; + $start = $this->unitStarts[$mid]; + if ($source < $start) { + $high = $mid - 1; + continue; + } + if ($source < $start + $this->unitSizes[$start]) { + return $this->newBase + $this->xlat[$start] + ($source - $start); + } + $low = $mid + 1; + } + + throw OpCacheException::unresolvedGraphReference(sprintf('pointer to 0x%x targets no persisted unit', $source)); + } + + /** + * Reads a uintptr_t pointer slot as a PHP int - the raw-pointer read + * primitive. The dereferenced CData element is always an integer at runtime; + * the guard states that to the analyser without widening any real value. + * + * @param \FFI\CData $slot a uintptr_t* view over the slot to read + */ + private function readSlot(object $slot): int + { + $value = $slot[0]; + \assert(\is_int($value)); + + return $value; + } + + /** + * Reads a pointer field's stored value as an integer (0 for C NULL). + */ + private function ptrValue(object $owner, string $field): int + { + if ($owner->$field === null) { + return 0; + } + + // A dynamically-named pointer field cannot be statically resolved, so + // FFI::addr() on the mixed field read is the one irreducible CData hop. + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) + return $this->readSlot(Core::cast('uintptr_t *', FFI::addr($owner->$field))); + } + + /** + * Writes a pointer field in the emit pass (no-op while measuring). The + * field always holds its non-null source value at this point. + * + * @param object $owner a view into the COPY + */ + private function put(object $owner, string $field, int $address): void + { + if ($this->phase !== 2) { + return; + } + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) + $slot = Core::cast('uintptr_t *', FFI::addr($owner->$field)); + $slot[0] = $address; + } + + /** Raw pointer-slot write in the emit pass */ + private function putAt(int $slotAddress, int $value): void + { + if ($this->phase !== 2) { + return; + } + Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress))[0] = $value; + } + + /** Reads a raw pointer slot */ + private function slotValue(int $slotAddress): int + { + return $this->readSlot(Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $slotAddress))); + } + + /** Defers a copy-slot rewrite until the xlat table is complete */ + private function deferAt(int $slotAddress, int $sourceTarget, string $what): void + { + if ($this->phase !== 2) { + return; + } + $this->deferred[] = [$slotAddress, $sourceTarget, $what]; + } + + /** + * @param object $owner a view into the COPY, field currently non-null + */ + private function defer(object $owner, string $field, int $sourceTarget, string $what): void + { + if ($this->phase !== 2) { + return; + } + // @phpstan-ignore argument.type (FFI::addr of a dynamic FFI\CData pointer field) + $this->deferred[] = [Core::addressOf(FFI::addr($owner->$field)), $sourceTarget, $what]; + } + + private function resolveDeferred(): void + { + foreach ($this->deferred as [$slotAddress, $sourceTarget, $what]) { + $this->putAt($slotAddress, $this->mapAddress($sourceTarget)); + } + $this->deferred = []; + } + + // --- strings -------------------------------------------------------------- + + /** + * Region-copies one zend_string (zend_accel_store_interned_string for the + * file_cache_only case: nothing is accel-interned, everything is memdup'd + * and stamped with the interned GC bits via zend_set_str_gc_flags). + */ + private function persistString(int $source): int + { + $string = Core::pointerAtAddress(zend_string::class, $source); + $size = $this->zendStringHeaderSize + $string->len + 1; + [$new, $first] = $this->unit($source, $size); + if ($first && $this->phase === 2) { + $copy = Core::pointerAtAddress(zend_string::class, $new); + $typeInfo = $copy->gc->u->type_info; + if (($typeInfo & Core::engineConstant('IS_STR_INTERNED')) === 0) { + // zend_set_str_gc_flags, file_cache_only branch + $copy->gc->refcount = 2; + $copy->gc->u->type_info = Core::engineConstant('GC_STRING') + | Core::engineConstant('IS_STR_INTERNED') + | ($typeInfo & Core::engineConstant('IS_STR_VALID_UTF8')); + } + } + + return $new; + } + + // --- hashtables (zend_hash_persist) --------------------------------------- + + /** + * Persists the DATA block of a hashtable and walks its live entries; the + * HashTable struct itself lives in its owner (embedded) or in its own unit + * (zend_array). $entry receives [source zval address, copy zval address]. + * + * @param HashTableStruct $ht source HashTable view + * @param object $htCopy copy HashTable view (same as $ht while measuring) + */ + private function persistHashData(object $ht, object $htCopy, callable $entry): void + { + /** @var HashTableStruct $ht Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + if (($ht->u->flags & Core::engineConstant('HASH_FLAG_UNINITIALIZED')) !== 0) { + return; // arData is written as 0 by the relocator's serialize stage + } + $dataAddress = $this->ptrValue($ht, 'arData'); + if ($dataAddress === 0) { + return; + } + $used = $ht->nNumUsed; + $packed = ($ht->u->flags & Core::engineConstant('HASH_FLAG_PACKED')) !== 0; + if ($packed) { + // Packed tables reserve HT_HASH_SIZE(HT_MIN_MASK) bytes before arData + $hashBytes = (0x100000000 - Core::engineConstant('HT_MIN_MASK')) * 4; + $entrySize = Core::sizeOfType(zval::class); + } else { + $hashBytes = (0x100000000 - $ht->nTableMask) * 4; + $entrySize = Core::sizeOfType(Bucket::class); + } + $dataStart = $dataAddress - $hashBytes; + $usedSize = $hashBytes + $used * $entrySize; + [$newStart, ] = $this->unit($dataStart, $usedSize); + $newData = $newStart + $hashBytes; + $this->put($htCopy, 'arData', $newData); + + for ($i = 0; $i < $used; $i++) { + $sourceEntry = $dataAddress + $i * $entrySize; + $copyEntry = $this->phase === 2 ? $newData + $i * $entrySize : $sourceEntry; + if ($packed) { + $zv = Core::pointerAtAddress(zval::class, $sourceEntry); + if ($zv->u1->v->type !== 0) { + $entry($sourceEntry, $copyEntry); + } + continue; + } + $bucket = Core::pointerAtAddress(Bucket::class, $sourceEntry); + if ($bucket->val->u1->v->type === 0) { + continue; // hole: bytes copied verbatim, nothing to walk + } + $keyAddress = $this->ptrValue($bucket, 'key'); + if ($keyAddress !== 0) { + $newKey = $this->persistString($keyAddress); + $bucketCopy = Core::pointerAtAddress(Bucket::class, $copyEntry); + if ($this->phase === 2) { + $this->put($bucketCopy, 'key', $newKey); + } + } + $entry($sourceEntry, $copyEntry); + } + } + + /** A pointed-to zend_array (IS_ARRAY zval, static_variables, attributes) */ + private function persistArray(int $source, callable $entry): int + { + [$new, $first] = $this->unit($source, Core::sizeOfType('HashTable')); + if ($first) { + $ht = Core::pointerAtAddress(HashTableStruct::class, $source); + $htCopy = $this->phase === 2 ? Core::pointerAtAddress(HashTableStruct::class, $new) : $ht; + $this->persistHashData($ht, $htCopy, $entry); + } + + return $new; + } + + // --- zvals ------------------------------------------------------------------ + + private function persistZval(int $source, int $copy): void + { + $zv = Core::pointerAtAddress(zval::class, $source); + $zvCopy = $this->phase === 2 ? Core::pointerAtAddress(zval::class, $copy) : $zv; + switch ($zv->u1->v->type) { + case self::IS_STRING: + $this->put($zvCopy->value, 'str', $this->persistString($this->ptrValue($zv->value, 'str'))); + break; + case self::IS_ARRAY: + $new = $this->persistArray( + $this->ptrValue($zv->value, 'arr'), + fn(int $s, int $c) => $this->persistZval($s, $c), + ); + $this->put($zvCopy->value, 'arr', $new); + break; + case self::IS_CONSTANT_AST: + $this->put($zvCopy->value, 'ast', $this->persistAstRef($this->ptrValue($zv->value, 'ast'))); + break; + case self::IS_INDIRECT: + // Points INTO another unit (a property-table slot): interior fixup + $this->defer($zvCopy->value, 'zv', $this->ptrValue($zv->value, 'zv'), 'IS_INDIRECT zval'); + break; + } + } + + // --- constant ASTs (zend_persist_ast) ---------------------------------------- + + /** The zend_ast_ref unit carries the root node inline, children are units */ + private function persistAstRef(int $source): int + { + $rootSource = $source + Core::sizeOfType(zend_ast_ref::class); + $refSize = Core::sizeOfType(zend_ast_ref::class) + $this->astNodeSize($rootSource); + [$new, $first] = $this->unit($source, $refSize); + if ($first) { + $this->persistAstNodeBody($rootSource, $new === 0 ? 0 : $new + Core::sizeOfType(zend_ast_ref::class)); + } + + return $new; + } + + private function persistAstNode(int $source): int + { + [$new, $first] = $this->unit($source, $this->astNodeSize($source)); + if ($first) { + $this->persistAstNodeBody($source, $new); + } + + return $new; + } + + private function persistAstNodeBody(int $source, int $copy): void + { + $ast = Core::pointerAtAddress(zend_ast::class, $source); + $kind = $ast->kind; + if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { + $valueOffset = Core::sizeOfType('zend_ast_zval') - Core::sizeOfType(zval::class); + $this->persistZval($source + $valueOffset, $copy + $valueOffset); + + return; + } + [$childBase, $count] = $this->astChildren($source, $kind); + for ($i = 0; $i < $count; $i++) { + $childSource = $this->slotValue($childBase + $i * PHP_INT_SIZE); + if ($childSource === 0) { + continue; + } + $new = $this->persistAstNode($childSource); + $this->putAt($copy + ($childBase - $source) + $i * PHP_INT_SIZE, $new); + } + } + + /** @return array{int, int} [child slot base address, child count] */ + private function astChildren(int $source, int $kind): array + { + if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { + $list = Core::pointerAtAddress(zend_ast_list::class, $source); + + return [$source + Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE, $list->children]; + } + + return [$source + Core::sizeOfType(zend_ast::class) - PHP_INT_SIZE, $kind >> self::ZEND_AST_CHILDREN_SHIFT]; + } + + private function astNodeSize(int $source): int + { + $ast = Core::pointerAtAddress(zend_ast::class, $source); + $kind = $ast->kind; + if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { + return Core::sizeOfType('zend_ast_zval'); + } + if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { + $list = Core::pointerAtAddress(zend_ast_list::class, $source); + + return Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE + PHP_INT_SIZE * $list->children; + } + + return Core::sizeOfType(zend_ast::class) - PHP_INT_SIZE + PHP_INT_SIZE * ($kind >> self::ZEND_AST_CHILDREN_SHIFT); + } + + // --- attributes ---------------------------------------------------------------- + + private function persistAttributes(object $owner, object $ownerCopy, string $field): void + { + $source = $this->ptrValue($owner, $field); + if ($source === 0) { + return; + } + $new = $this->persistArray($source, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress(zval::class, $zvalSource); + $attrSource = $this->ptrValue($zv->value, 'ptr'); + $attr = Core::pointerAtAddress(zend_attribute::class, $attrSource); + $argSize = Core::sizeOfType(zend_attribute_arg::class); + // ZEND_ATTRIBUTE_SIZE(argc) + $size = Core::sizeOfType(zend_attribute::class) + $argSize * $attr->argc - $argSize; + [$new, $first] = $this->unit($attrSource, $size); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'ptr', $new); + } + if (!$first) { + return; + } + $attrCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_attribute::class, $new) : $attr; + $this->put($attrCopy, 'name', $this->persistString($this->ptrValue($attr, 'name'))); + $this->put($attrCopy, 'lcname', $this->persistString($this->ptrValue($attr, 'lcname'))); + $argBase = Core::addressOf($attr->args); + for ($i = 0; $i < $attr->argc; $i++) { + $argSource = $argBase + $i * $argSize; + $argCopy = $this->phase === 2 ? Core::addressOf($attrCopy->args) + $i * $argSize : $argSource; + $arg = Core::pointerAtAddress(zend_attribute_arg::class, $argSource); + $nameAddr = $this->ptrValue($arg, 'name'); + if ($nameAddr !== 0) { + $this->put(Core::pointerAtAddress(zend_attribute_arg::class, $argCopy), 'name', $this->persistString($nameAddr)); + } + $valueOffset = $argSize - Core::sizeOfType(zval::class); + $this->persistZval($argSource + $valueOffset, $argCopy + $valueOffset); + } + }); + $this->put($ownerCopy, $field, $new); + } + + // --- types ------------------------------------------------------------------------ + + /** + * @param zend_type $type source zend_type view (embedded) + * @param object $typeCopy copy zend_type view + */ + private function persistType(object $type, object $typeCopy): void + { + /** @var zend_type $type Narrowed to the stub view at the boundary; the runtime value is FFI\CData */ + $typeMask = $type->type_mask; + if (($typeMask & self::TYPE_LIST_BIT) !== 0) { + $listSource = $this->ptrValue($type, 'ptr'); + $list = Core::pointerAtAddress(zend_type_list::class, $listSource); + $typeSize = Core::sizeOfType(zend_type::class); + $entryBase = Core::sizeOfType(zend_type_list::class) - $typeSize; + $size = $entryBase + $typeSize * $list->num_types; + [$new, $first] = $this->unit($listSource, $size); + $this->put($typeCopy, 'ptr', $new); + if ($first) { + for ($i = 0; $i < $list->num_types; $i++) { + $entrySource = Core::pointerAtAddress(zend_type::class, $listSource + $entryBase + $i * $typeSize); + $entryCopy = $this->phase === 2 + ? Core::pointerAtAddress(zend_type::class, $new + $entryBase + $i * $typeSize) + : $entrySource; + $this->persistType($entrySource, $entryCopy); + } + } + + return; + } + if (($typeMask & self::TYPE_NAME_BIT) !== 0) { + $this->put($typeCopy, 'ptr', $this->persistString($this->ptrValue($type, 'ptr'))); + } + } + + // --- op_arrays (zend_persist_op_array) ------------------------------------------------- + + /** Persists a pointed-to zend_function unit (function table entries, hooks, closures) */ + private function persistFunction(int $source): int + { + $opArray = Core::pointerAtAddress(zend_op_array::class, $source); + if ($opArray->type !== Core::engineConstant('ZEND_USER_FUNCTION')) { + throw OpCacheException::unsupportedPayload('only user functions can be persisted into a file-cache image'); + } + [$new, $first] = $this->unit($source, Core::sizeOfType(zend_op_array::class)); + if ($first) { + $this->persistOpArrayBody($source, $new); + } + + return $new; + } + + /** The shared field walk for pointed-to op_arrays and the embedded main_op_array */ + private function persistOpArrayBody(int $source, int $copy): void + { + $op = Core::pointerAtAddress(zend_op_array::class, $source); + $opCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_op_array::class, $copy) : $op; + + $staticVariables = $this->ptrValue($op, 'static_variables'); + if ($staticVariables !== 0) { + $new = $this->persistArray($staticVariables, fn(int $s, int $c) => $this->persistZval($s, $c)); + $this->put($opCopy, 'static_variables', $new); + } + + $literals = $this->ptrValue($op, 'literals'); + if ($literals !== 0) { + $zvalSize = Core::sizeOfType(zval::class); + [$new, $first] = $this->unit($literals, $op->last_literal * $zvalSize); + $this->put($opCopy, 'literals', $new); + if ($first) { + for ($i = 0; $i < $op->last_literal; $i++) { + $this->persistZval($literals + $i * $zvalSize, $new + $i * $zvalSize); + } + } + } + + $opcodes = $this->ptrValue($op, 'opcodes'); + if ($opcodes !== 0) { + // Byte-verbatim: payload oplines are already file-form (handler + // indexes, literal-index operands, relative jumps) + [$new, ] = $this->unit($opcodes, $op->last * Core::sizeOfType('zend_op')); + $this->put($opCopy, 'opcodes', $new); + } + + $argInfo = $this->ptrValue($op, 'arg_info'); + if ($argInfo !== 0) { + $argSize = Core::sizeOfType(zend_arg_info::class); + $hasRet = ($op->fn_flags & 0x2000) !== 0 ? 1 : 0; // ZEND_ACC_HAS_RETURN_TYPE + $variadic = ($op->fn_flags & 0x4000) !== 0 ? 1 : 0; // ZEND_ACC_VARIADIC + $entries = $op->num_args + $hasRet + $variadic; + // The allocation starts at the return-type slot (arg_info[-1]) + $allocStart = $argInfo - $hasRet * $argSize; + [$new, $first] = $this->unit($allocStart, $entries * $argSize); + $this->put($opCopy, 'arg_info', $new + $hasRet * $argSize); + if ($first) { + for ($i = 0; $i < $entries; $i++) { + $entrySource = Core::pointerAtAddress(zend_arg_info::class, $allocStart + $i * $argSize); + $entryCopy = $this->phase === 2 + ? Core::pointerAtAddress(zend_arg_info::class, $new + $i * $argSize) + : $entrySource; + $nameAddress = $this->ptrValue($entrySource, 'name'); + if ($nameAddress !== 0) { + $this->put($entryCopy, 'name', $this->persistString($nameAddress)); + } + $this->persistType($entrySource->type, $entryCopy->type); + } + } + } + + $vars = $this->ptrValue($op, 'vars'); + if ($vars !== 0) { + [$new, $first] = $this->unit($vars, $op->last_var * PHP_INT_SIZE); + $this->put($opCopy, 'vars', $new); + if ($first) { + for ($i = 0; $i < $op->last_var; $i++) { + $stringAddress = $this->slotValue($vars + $i * PHP_INT_SIZE); + if ($stringAddress !== 0) { + $this->putAt($new + $i * PHP_INT_SIZE, $this->persistString($stringAddress)); + } + } + } + } + + foreach (['function_name', 'filename', 'doc_comment'] as $stringField) { + $address = $this->ptrValue($op, $stringField); + if ($address !== 0) { + $this->put($opCopy, $stringField, $this->persistString($address)); + } + } + + $liveRange = $this->ptrValue($op, 'live_range'); + if ($liveRange !== 0) { + [$new, ] = $this->unit($liveRange, $op->last_live_range * Core::sizeOfType(zend_live_range::class)); + $this->put($opCopy, 'live_range', $new); + } + + $this->persistAttributes($op, $opCopy, 'attributes'); + + $tryCatch = $this->ptrValue($op, 'try_catch_array'); + if ($tryCatch !== 0) { + [$new, ] = $this->unit($tryCatch, $op->last_try_catch * Core::sizeOfType(zend_try_catch_element::class)); + $this->put($opCopy, 'try_catch_array', $new); + } + + if ($op->num_dynamic_func_defs !== 0) { + $defs = $this->ptrValue($op, 'dynamic_func_defs'); + [$new, $first] = $this->unit($defs, $op->num_dynamic_func_defs * PHP_INT_SIZE); + $this->put($opCopy, 'dynamic_func_defs', $new); + if ($first) { + for ($i = 0; $i < $op->num_dynamic_func_defs; $i++) { + $defSource = $this->slotValue($defs + $i * PHP_INT_SIZE); + $this->putAt($new + $i * PHP_INT_SIZE, $this->persistFunction($defSource)); + } + } + } + + foreach ([ + 'scope' => 'op_array scope', + 'prototype' => 'op_array prototype', + 'prop_info' => 'op_array prop_info (hook back-reference)', + ] as $field => $what) { + $target = $this->ptrValue($op, $field); + if ($target !== 0) { + $this->defer($opCopy, $field, $target, $what); + } + } + // refcount / run_time_cache / static_variables_ptr map slots are copied + // verbatim: sources are persisted images where they already hold the + // file-form values (NULL, or the shared-body -1 refcount marker) + } + + // --- classes (zend_persist_class_entry, the non-LINKED branch) --------------------------- + + private function persistClassEntry(int $source): int + { + [$ceNew, $first] = $this->unit($source, Core::sizeOfType(zend_class_entry::class)); + if (!$first) { + return $ceNew; + } + $ce = Core::pointerAtAddress(zend_class_entry::class, $source); + $ceCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_class_entry::class, $ceNew) : $ce; + + $this->put($ceCopy, 'name', $this->persistString($this->ptrValue($ce, 'name'))); + if ($this->ptrValue($ce, 'parent') !== 0) { + if (($ce->ce_flags & Core::engineConstant('ZEND_ACC_LINKED')) !== 0) { + $this->defer($ceCopy, 'parent', $this->ptrValue($ce, 'parent'), 'linked parent class'); + } else { + $this->put($ceCopy, 'parent_name', $this->persistString($this->ptrValue($ce, 'parent_name'))); + } + } + + $this->persistHashData($ce->function_table, $ceCopy->function_table, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress(zval::class, $zvalSource); + $new = $this->persistFunction($this->ptrValue($zv->value, 'func')); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'func', $new); + } + }); + + foreach ([ + 'default_properties_table' => $ce->default_properties_count, + 'default_static_members_table' => $ce->default_static_members_count, + ] as $tableField => $count) { + $table = $this->ptrValue($ce, $tableField); + if ($table === 0) { + continue; + } + $zvalSize = Core::sizeOfType(zval::class); + [$new, $first] = $this->unit($table, $count * $zvalSize); + $this->put($ceCopy, $tableField, $new); + if ($first) { + for ($i = 0; $i < $count; $i++) { + $this->persistZval($table + $i * $zvalSize, $new + $i * $zvalSize); + } + } + } + + $this->persistHashData($ce->constants_table, $ceCopy->constants_table, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress(zval::class, $zvalSource); + $constSource = $this->ptrValue($zv->value, 'ptr'); + [$new, $first] = $this->unit($constSource, Core::sizeOfType(zend_class_constant::class)); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'ptr', $new); + } + if (!$first) { + return; + } + $constant = Core::pointerAtAddress(zend_class_constant::class, $constSource); + $constantCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_class_constant::class, $new) : $constant; + $this->persistZval($constSource, $this->phase === 2 ? $new : $constSource); // value zval is the first member + $docComment = $this->ptrValue($constant, 'doc_comment'); + if ($docComment !== 0) { + $this->put($constantCopy, 'doc_comment', $this->persistString($docComment)); + } + $this->persistAttributes($constant, $constantCopy, 'attributes'); + $this->defer($constantCopy, 'ce', $this->ptrValue($constant, 'ce'), 'class constant scope'); + $this->persistType($constant->type, $constantCopy->type); + }); + + $filename = $this->ptrValue($ce->info->user, 'filename'); + if ($filename !== 0) { + $this->put($ceCopy->info->user, 'filename', $this->persistString($filename)); + } + $docComment = $this->ptrValue($ce, 'doc_comment'); + if ($docComment !== 0) { + $this->put($ceCopy, 'doc_comment', $this->persistString($docComment)); + } + $this->persistAttributes($ce, $ceCopy, 'attributes'); + + $this->persistHashData($ce->properties_info, $ceCopy->properties_info, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress(zval::class, $zvalSource); + $propSource = $this->ptrValue($zv->value, 'ptr'); + [$new, $first] = $this->unit($propSource, Core::sizeOfType(zend_property_info::class)); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'ptr', $new); + } + if (!$first) { + return; + } + $prop = Core::pointerAtAddress(zend_property_info::class, $propSource); + $propCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_property_info::class, $new) : $prop; + $this->defer($propCopy, 'ce', $this->ptrValue($prop, 'ce'), 'property scope'); + $this->put($propCopy, 'name', $this->persistString($this->ptrValue($prop, 'name'))); + $docComment = $this->ptrValue($prop, 'doc_comment'); + if ($docComment !== 0) { + $this->put($propCopy, 'doc_comment', $this->persistString($docComment)); + } + $this->persistAttributes($prop, $propCopy, 'attributes'); + $prototype = $this->ptrValue($prop, 'prototype'); + if ($prototype !== 0) { + $this->defer($propCopy, 'prototype', $prototype, 'property prototype'); + } + $hooks = $this->ptrValue($prop, 'hooks'); + if ($hooks !== 0) { + [$newHooks, $firstHooks] = $this->unit($hooks, self::PROPERTY_HOOK_COUNT * PHP_INT_SIZE); + $this->put($propCopy, 'hooks', $newHooks); + if ($firstHooks) { + for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { + $hookSource = $this->slotValue($hooks + $i * PHP_INT_SIZE); + if ($hookSource !== 0) { + $this->putAt($newHooks + $i * PHP_INT_SIZE, $this->persistFunction($hookSource)); + } + } + } + } + $this->persistType($prop->type, $propCopy->type); + }); + + $propTable = $this->ptrValue($ce, 'properties_info_table'); + if ($propTable !== 0) { + [$new, $first] = $this->unit($propTable, $ce->default_properties_count * PHP_INT_SIZE); + $this->put($ceCopy, 'properties_info_table', $new); + if ($first) { + for ($i = 0; $i < $ce->default_properties_count; $i++) { + $slotTarget = $this->slotValue($propTable + $i * PHP_INT_SIZE); + if ($slotTarget !== 0) { + $this->deferAt($new + $i * PHP_INT_SIZE, $slotTarget, 'properties_info_table entry'); + } + } + } + } + + if ($ce->num_interfaces !== 0) { + if (($ce->ce_flags & Core::engineConstant('ZEND_ACC_LINKED')) !== 0) { + // Mirrors the ZEND_ASSERT in zend_file_cache_serialize_class + throw OpCacheException::unsupportedPayload('a linked class with interfaces cannot be re-serialized'); + } + $this->persistClassNames($ce, $ceCopy, 'interface_names', $ce->num_interfaces); + } + if ($ce->num_traits !== 0) { + $this->persistClassNames($ce, $ceCopy, 'trait_names', $ce->num_traits); + $this->persistTraitAliases($ce, $ceCopy); + $this->persistTraitPrecedences($ce, $ceCopy); + } + + foreach (self::MAGIC_METHOD_FIELDS as $field) { + $target = $this->ptrValue($ce, $field); + if ($target !== 0) { + $this->defer($ceCopy, $field, $target, "magic method {$field}"); + } + } + + $iteratorFuncs = $this->ptrValue($ce, 'iterator_funcs_ptr'); + if ($iteratorFuncs !== 0) { + [$new, $first] = $this->unit($iteratorFuncs, Core::sizeOfType(zend_class_iterator_funcs::class)); + $this->put($ceCopy, 'iterator_funcs_ptr', $new); + if ($first) { + $funcs = Core::pointerAtAddress(zend_class_iterator_funcs::class, $iteratorFuncs); + $funcsCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_class_iterator_funcs::class, $new) : $funcs; + foreach (self::ITERATOR_FUNC_FIELDS as $field) { + $target = $this->ptrValue($funcs, $field); + if ($target !== 0) { + $this->defer($funcsCopy, $field, $target, "iterator {$field}"); + } + } + } + } + $arrayAccessFuncs = $this->ptrValue($ce, 'arrayaccess_funcs_ptr'); + if ($arrayAccessFuncs !== 0) { + [$new, $first] = $this->unit($arrayAccessFuncs, Core::sizeOfType(zend_class_arrayaccess_funcs::class)); + $this->put($ceCopy, 'arrayaccess_funcs_ptr', $new); + if ($first) { + $funcs = Core::pointerAtAddress(zend_class_arrayaccess_funcs::class, $arrayAccessFuncs); + $funcsCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_class_arrayaccess_funcs::class, $new) : $funcs; + foreach (self::ARRAYACCESS_FUNC_FIELDS as $field) { + $target = $this->ptrValue($funcs, $field); + if ($target !== 0) { + $this->defer($funcsCopy, $field, $target, "arrayaccess {$field}"); + } + } + } + } + + // zend_persist_class_entry: the inheritance cache never survives a persist + if ($this->phase === 2 && $this->ptrValue($ceCopy, 'inheritance_cache') !== 0) { + $this->put($ceCopy, 'inheritance_cache', 0); + } + + return $ceNew; + } + + private function persistClassNames(object $ce, object $ceCopy, string $field, int $count): void + { + $source = $this->ptrValue($ce, $field); + if ($source === 0) { + return; + } + $nameSize = Core::sizeOfType(zend_class_name::class); + [$new, $first] = $this->unit($source, $count * $nameSize); + $this->put($ceCopy, $field, $new); + if (!$first) { + return; + } + for ($i = 0; $i < $count; $i++) { + $entrySource = Core::pointerAtAddress(zend_class_name::class, $source + $i * $nameSize); + $entryCopy = $this->phase === 2 + ? Core::pointerAtAddress(zend_class_name::class, $new + $i * $nameSize) + : $entrySource; + $this->put($entryCopy, 'name', $this->persistString($this->ptrValue($entrySource, 'name'))); + $this->put($entryCopy, 'lc_name', $this->persistString($this->ptrValue($entrySource, 'lc_name'))); + } + } + + private function persistTraitAliases(object $ce, object $ceCopy): void + { + $source = $this->ptrValue($ce, 'trait_aliases'); + if ($source === 0) { + return; + } + $count = 0; + while ($this->slotValue($source + $count * PHP_INT_SIZE) !== 0) { + $count++; + } + [$new, $first] = $this->unit($source, ($count + 1) * PHP_INT_SIZE); + $this->put($ceCopy, 'trait_aliases', $new); + if (!$first) { + return; + } + for ($i = 0; $i < $count; $i++) { + $aliasSource = $this->slotValue($source + $i * PHP_INT_SIZE); + [$newAlias, $firstAlias] = $this->unit($aliasSource, Core::sizeOfType(zend_trait_alias::class)); + $this->putAt($new + $i * PHP_INT_SIZE, $newAlias); + if (!$firstAlias) { + continue; + } + $alias = Core::pointerAtAddress(zend_trait_alias::class, $aliasSource); + $aliasCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_trait_alias::class, $newAlias) : $alias; + foreach (['method_name', 'class_name'] as $nameField) { + $address = $this->ptrValue($alias->trait_method, $nameField); + if ($address !== 0) { + $this->put($aliasCopy->trait_method, $nameField, $this->persistString($address)); + } + } + $address = $this->ptrValue($alias, 'alias'); + if ($address !== 0) { + $this->put($aliasCopy, 'alias', $this->persistString($address)); + } + } + } + + private function persistTraitPrecedences(object $ce, object $ceCopy): void + { + $source = $this->ptrValue($ce, 'trait_precedences'); + if ($source === 0) { + return; + } + $count = 0; + while ($this->slotValue($source + $count * PHP_INT_SIZE) !== 0) { + $count++; + } + [$new, $first] = $this->unit($source, ($count + 1) * PHP_INT_SIZE); + $this->put($ceCopy, 'trait_precedences', $new); + if (!$first) { + return; + } + for ($i = 0; $i < $count; $i++) { + $precedenceSource = $this->slotValue($source + $i * PHP_INT_SIZE); + $precedence = Core::pointerAtAddress(zend_trait_precedence::class, $precedenceSource); + $size = Core::sizeOfType(zend_trait_precedence::class) + + PHP_INT_SIZE * ($precedence->num_excludes - 1); + [$newPrecedence, $firstPrecedence] = $this->unit($precedenceSource, $size); + $this->putAt($new + $i * PHP_INT_SIZE, $newPrecedence); + if (!$firstPrecedence) { + continue; + } + $precedenceCopy = $this->phase === 2 + ? Core::pointerAtAddress(zend_trait_precedence::class, $newPrecedence) + : $precedence; + foreach (['method_name', 'class_name'] as $nameField) { + $address = $this->ptrValue($precedence->trait_method, $nameField); + if ($address !== 0) { + $this->put($precedenceCopy->trait_method, $nameField, $this->persistString($address)); + } + } + $excludeBase = Core::addressOf($precedence->exclude_class_names); + $excludeCopyBase = $this->phase === 2 ? Core::addressOf($precedenceCopy->exclude_class_names) : $excludeBase; + for ($j = 0; $j < $precedence->num_excludes; $j++) { + $address = $this->slotValue($excludeBase + $j * PHP_INT_SIZE); + if ($address !== 0) { + $this->putAt($excludeCopyBase + $j * PHP_INT_SIZE, $this->persistString($address)); + } + } + } + } + + // --- the script root ----------------------------------------------------------------- + + private function persistScript(int $source): void + { + [$new, ] = $this->unit($source, Core::sizeOfType(zend_persistent_script::class)); + $script = Core::pointerAtAddress(zend_persistent_script::class, $source); + $scriptCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_persistent_script::class, $new) : $script; + + $this->put($scriptCopy->script, 'filename', $this->persistString($this->ptrValue($script->script, 'filename'))); + + $this->persistHashData($script->script->class_table, $scriptCopy->script->class_table, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress(zval::class, $zvalSource); + $new = $this->persistClassEntry($this->ptrValue($zv->value, 'ce')); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'ce', $new); + } + }); + $this->persistHashData($script->script->function_table, $scriptCopy->script->function_table, function (int $zvalSource, int $zvalCopy): void { + $zv = Core::pointerAtAddress(zval::class, $zvalSource); + $new = $this->persistFunction($this->ptrValue($zv->value, 'func')); + if ($this->phase === 2) { + $this->put(Core::pointerAtAddress(zval::class, $zvalCopy)->value, 'func', $new); + } + }); + + $mainSource = Core::addressOf(Core::addr($script->script->main_op_array)); + $mainCopy = $this->phase === 2 ? Core::addressOf(Core::addr($scriptCopy->script->main_op_array)) : $mainSource; + $this->persistOpArrayBody($mainSource, $mainCopy); + + $warnings = $this->ptrValue($script, 'warnings'); + if ($warnings !== 0) { + [$new, $first] = $this->unit($warnings, $script->num_warnings * PHP_INT_SIZE); + $this->put($scriptCopy, 'warnings', $new); + if ($first) { + for ($i = 0; $i < $script->num_warnings; $i++) { + $warningSource = $this->slotValue($warnings + $i * PHP_INT_SIZE); + [$newWarning, $firstWarning] = $this->unit($warningSource, Core::sizeOfType(zend_error_info::class)); + $this->putAt($new + $i * PHP_INT_SIZE, $newWarning); + if (!$firstWarning) { + continue; + } + $warning = Core::pointerAtAddress(zend_error_info::class, $warningSource); + $warningCopy = $this->phase === 2 ? Core::pointerAtAddress(zend_error_info::class, $newWarning) : $warning; + foreach (['filename', 'message'] as $stringField) { + $address = $this->ptrValue($warning, $stringField); + if ($address !== 0) { + $this->put($warningCopy, $stringField, $this->persistString($address)); + } + } + } + } + } + + $earlyBindings = $this->ptrValue($script, 'early_bindings'); + if ($earlyBindings !== 0) { + $bindingSize = Core::sizeOfType(zend_early_binding::class); + [$new, $first] = $this->unit($earlyBindings, $script->num_early_bindings * $bindingSize); + $this->put($scriptCopy, 'early_bindings', $new); + if ($first) { + for ($i = 0; $i < $script->num_early_bindings; $i++) { + $bindingSource = Core::pointerAtAddress(zend_early_binding::class, $earlyBindings + $i * $bindingSize); + $bindingCopy = $this->phase === 2 + ? Core::pointerAtAddress(zend_early_binding::class, $new + $i * $bindingSize) + : $bindingSource; + foreach (['lcname', 'rtd_key', 'lc_parent_name'] as $stringField) { + $address = $this->ptrValue($bindingSource, $stringField); + if ($address !== 0) { + $this->put($bindingCopy, $stringField, $this->persistString($address)); + } + } + } + } + } + + if ($this->phase === 2) { + // script->size is load-bearing: the loader's IS_SERIALIZED bound. + // script->mem is rewritten by zend_file_cache_unserialize on load. + $scriptCopy->size = $this->total; + if ($this->ptrValue($scriptCopy, 'mem') !== 0) { + $this->put($scriptCopy, 'mem', 0); + } + } + } +} diff --git a/src/OpCache/SharedMemoryException.php b/src/OpCache/SharedMemoryException.php index 8b617f09..d0110a6f 100644 --- a/src/OpCache/SharedMemoryException.php +++ b/src/OpCache/SharedMemoryException.php @@ -83,13 +83,15 @@ public static function functionNotPublished(string $lowerKey): self } /** - * Handler installation targeted a temporary lazy-linking class copy (issue #238) + * Handler installation targeted a temporary lazy-linking class copy while declining + * the inheritance cache is unavailable (issues #238/#241) * * Handlers are tracked per class-entry address; the lazy-linking temporary is * discarded as soon as opcache's inheritance cache persists the linked class, so - * anything installed on it would be silently lost. Throwing here is the loud - * failure until declining the inheritance cache for hooked classes (issue #241) - * makes the installation actually stick. + * anything installed on it would be silently lost. Normally z-engine keeps such a + * class process-local by declining its inheritance-cache publication (issue #241); + * this loud fallback fires only when the interception cannot be installed - engine + * definitions generated before the zend_inheritance_cache_add symbol was exported. */ public static function handlerInstallationDuringLazyLinking(string $className, string $handlerName): self { @@ -97,8 +99,10 @@ public static function handlerInstallationDuringLazyLinking(string $className, s "Cannot install the {$handlerName} handler on {$className}: the class entry is the " . 'temporary copy opcache links classes on (lazy loading for the inheritance cache) ' . 'and discards when linking completes, so the installed handlers would be silently ' - . 'lost (issue #238). Install handlers after the class is fully linked, or run this ' - . 'code path with opcache disabled', + . 'lost (issue #238), and the generated engine definitions of this platform predate ' + . 'the zend_inheritance_cache_add interception that would keep the class process-local ' + . '(issue #241). Regenerate the engine definitions with `composer gen-headers`, install ' + . 'handlers after the class is fully linked, or run this code path with opcache disabled', ); } } diff --git a/src/Reflection/ClassSpecializer.php b/src/Reflection/ClassSpecializer.php index be285daa..2724cb5f 100644 --- a/src/Reflection/ClassSpecializer.php +++ b/src/Reflection/ClassSpecializer.php @@ -1286,13 +1286,18 @@ private function patchReceiveOpcodes( // A cached mask carrying a name or list bit means the compiler already routed this // parameter through the generic path that reads arg_info on every call, so the - // rewrite is live without touching an opcode - and the opcodes stay shared, which - // is both cheaper and free of the 32-bit reach limit below. + // rewrite is live without touching an opcode - and the opcodes stay shared with + // the template, which is the cheapest outcome of all. $routedThroughArgInfo = ($cachedMask & ( Core::engineConstant('_ZEND_TYPE_NAME_BIT') | Core::engineConstant('_ZEND_TYPE_LIST_BIT') )) !== 0; if (!$routedThroughArgInfo && $cachedMask !== $newMask) { - $patches[$index] = $newMask; + // Handlers are picked per OPLINE, not per opcode: opcache's optimizer gives a + // RECV whose cached mask is exactly MAY_BE_ANY (a `mixed` parameter) the + // RECV_NOTYPE variant, which tests nothing at all - so patching only the mask + // of such an opline would be silently unenforced. builtinTypeMask('mixed') IS + // MAY_BE_ANY, the very value the specialization rule compares against. + $patches[$index] = [$newMask, $cachedMask === self::builtinTypeMask('mixed')]; } } if ($patches === []) { @@ -1300,33 +1305,120 @@ private function patchReceiveOpcodes( } $copiedOpcodes = $this->duplicateOpcodes($opArrayCopy, $sourceOpArray, $total); - foreach ($patches as $index => $newMask) { + foreach ($patches as $index => [$newMask, $needsCheckingHandler]) { $patched = $copiedOpcodes[$index]; assert($patched instanceof CData); $cachedMask = $patched->op2; assert($cachedMask instanceof CData); $cachedMask->num = $newMask; + if ($needsCheckingHandler) { + self::restoreGenericReceiveHandler($patched); + } } } /** - * Copies a method's opcode array into request memory so the copy can be written to + * Rebinds a patched RECV opline to the engine's generic, mask-checking handler + * + * pass_two() assigns every RECV the generic handler, which tests `op2.num` on each call. + * Opcache's optimizer re-derives handlers with type-specialization rules and assigns + * `ZEND_RECV_NOTYPE` - a variant that receives the argument WITHOUT any check - to every + * RECV whose cached mask equals MAY_BE_ANY, because a `mixed` parameter accepts + * everything. A mask patched onto such an opline would never be read, so the opline is + * rebound to the generic handler, exactly what the compiler assigns when a builtin + * parameter type is written in source. The handler value comes from a donor opline that + * is generic in every compile mode (see receiveHandlerDonor()); it is transplanted + * through a pointer-sized integer view because FFI wraps C function pointers in opaque + * closure handles that cannot be copied field-to-field. + * + * @param \FFI\CData $patchedOpline The relocated (request-memory) RECV opline to rebind + */ + private static function restoreGenericReceiveHandler(object $patchedOpline): void + { + $donor = Core::cast( + 'zend_op_array *', + (new ReflectionMethod(self::class, 'receiveHandlerDonor'))->getEntryPointer(), + ); + $donorOpcodes = $donor->opcodes; + assert($donorOpcodes instanceof CData); + $donorOpline = $donorOpcodes[0]; + assert($donorOpline instanceof CData); + if ($donorOpline->opcode !== OpCode::RECV) { + throw new ClassSpecializationException( + 'Cannot rebind the RECV handler: the donor method did not compile to a leading ' + . 'ZEND_RECV opline, which indicates an engine behavior change', + ); + } + $handlerOffset = Core::type('zend_op')->getStructFieldOffset('handler'); + $donorHandler = Core::pointerAtAddress('uintptr_t *', Core::addressOf($donorOpcodes) + $handlerOffset)[0]; + assert(is_int($donorHandler)); + $patchedAddress = Core::addressOf(Core::addr($patchedOpline)); + self::storeHandlerSlot(Core::pointerAtAddress('uintptr_t *', $patchedAddress + $handlerOffset), $donorHandler); + } + + /** + * Stores one raw handler value into the pointer-sized slot of an opline + * + * The write mutates engine-visible memory behind the FFI pointer, which static + * analysis cannot see - hence the explicit impurity marker. + * + * @phpstan-impure + * @param \FFI\CData $handlerSlot + */ + private static function storeHandlerSlot(object $handlerSlot, int $handlerValue): void + { + $handlerSlot[0] = $handlerValue; + } + + /** + * Donor of the engine's generic, mask-checking ZEND_RECV handler + * + * Never called - it exists to be COMPILED. An `int` parameter caches MAY_BE_LONG, a mask + * the optimizer's RECV_NOTYPE specialization rule (`op2.num == MAY_BE_ANY`) can never + * match, so the first opline of this method carries the generic RECV handler in every + * compile mode - plain pass_two() and opcache-optimized alike. restoreGenericReceiveHandler() + * reads it from here instead of hardcoding VM internals. + */ + // @phpstan-ignore method.unused (looked up by name through ReflectionMethod, never called) + private static function receiveHandlerDonor(int $probe): void {} + + /** + * Copies a method's opcode array - literals included - into request memory so the copy + * can be written to + * + * The copy reproduces the engine's own pass_two() layout in one request-memory block: + * opcodes at the start, the literal zvals at the same 16-aligned offset right behind them + * (`ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * last, 16)`). Copying the literals WITH the + * opcodes is what makes the relocation universally valid: an IS_CONST operand is a *signed + * 32-bit* byte offset from the opline itself, and while the source pair always sat together, + * the source literals can be arbitrarily far from the relocated opcodes - an opcache-shared + * body lives in an mmap'd region well over 2GB from the request heap. With both halves in + * one block every rebased offset is bounded by the block size and always fits. * - * Two of the three operand encodings survive a straight memcpy and one does not: + * Two of the three operand encodings survive the memcpy untouched and one is rebased: * * - jump targets are *signed* byte offsets from the opline itself, so they survive because - * the whole array moves as a unit and every target keeps the same relative distance; + * the opcode array moves as a unit and every target keeps the same relative distance; * - `live_range` and `try_catch_array` address oplines by index, so they are unaffected; * - **IS_CONST operands are byte offsets from the opline itself** (the engine resolves them - * as `(char *) opline + node.constant`, and literals sit immediately after the opcodes in - * one compiler-arena block), so every one of them has to be rebased by the distance the - * array moved. - * - * Ownership mirrors the duplicated arg_info blocks: `destroy_op_array()` frees whichever - * `opcodes` pointer its holder carries once the shared body refcount reaches zero, so one - * sibling block is released through the engine and the other is reclaimed by the request - * allocator at request end. Bounded at one block per patched method. An opcache-shared - * source is safe because it is only ever read - the copy is what gets written. + * as `(char *) opline + node.constant`), so every one of them is rebased to address the + * copied literal at the very index its source addressed. + * + * The literal zvals are copied SHALLOWLY - the copy references the same payloads (strings, + * arrays, ASTs) as the source, exactly like the engine treats the two blocks as one shared + * body: releases happen only when the shared body refcount reaches zero, so exactly one + * dtor pass ever runs over exactly one of the sibling zval arrays. An opcache-shared source + * never even reaches that pass - its body refcount pointer is NULL, destroy_op_array() + * returns before touching literals, and the immortal shared-memory payloads (interned + * strings, immutable arrays) are never refcounted at all. + * + * Ownership mirrors the duplicated arg_info blocks: with relative IS_CONST addressing the + * engine frees literals and opcodes as ONE allocation through the `opcodes` pointer (it + * never efree()s `literals` separately once ZEND_ACC_DONE_PASS_TWO is set, which this block + * layout is built for), so one sibling block is released through the engine when the shared + * refcount hits zero and the other is reclaimed by the request allocator at request end. + * Bounded at one block per patched method. An opcache-shared source is safe because it is + * only ever read - the copy is what gets written. * * @return CData The copied zend_op[] block * @param \FFI\CData $opArrayCopy @@ -1335,72 +1427,95 @@ private function patchReceiveOpcodes( private function duplicateOpcodes(object $opArrayCopy, object $sourceOpArray, int $total): object { $sourceOpcodes = $sourceOpArray->opcodes; - assert($sourceOpcodes instanceof CData); + $totalLiterals = $sourceOpArray->last_literal; + assert($sourceOpcodes instanceof CData && is_int($totalLiterals)); $opcodeSize = Core::sizeOfType(zend_op::class); - $memory = Core::new("zend_op[{$total}]", false); - Core::memcpy($memory, $sourceOpcodes, $total * $opcodeSize); + $zvalSize = Core::sizeOfType(zval::class); - $sourceBase = Core::addressOf($sourceOpcodes); - $copyBase = Core::addressOf(Core::cast('zend_op *', Core::addr($memory))); - $shift = $sourceBase - $copyBase; + // ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * last, 16): the engine's own literal offset + $literalsOffset = ($total * $opcodeSize + 15) & ~15; + $blockSize = $literalsOffset + $totalLiterals * $zvalSize; + $memory = Core::new("char[{$blockSize}]", false); + $copiedOpcodes = Core::cast('zend_op *', $memory); + Core::memcpy($copiedOpcodes, $sourceOpcodes, $total * $opcodeSize); + + $copyBase = Core::addressOf($copiedOpcodes); + $opcodeShift = Core::addressOf($sourceOpcodes) - $copyBase; + + $literalShift = null; + if ($totalLiterals > 0) { + $sourceLiterals = $sourceOpArray->literals; + assert($sourceLiterals instanceof CData); + $copiedLiterals = Core::pointerAtAddress('zval *', $copyBase + $literalsOffset); + Core::memcpy($copiedLiterals, $sourceLiterals, $totalLiterals * $zvalSize); + $literalShift = Core::addressOf($sourceLiterals) - ($copyBase + $literalsOffset); + $opArrayCopy->literals = $copiedLiterals; + } for ($index = 0; $index < $total; $index++) { - $opline = $memory[$index]; + $opline = $copiedOpcodes[$index]; assert($opline instanceof CData); foreach (self::CONSTANT_OPERAND_FIELDS as $typeField => $operandField) { if ($opline->{$typeField} !== OpLine::IS_CONST) { continue; } + // An IS_CONST operand always addresses a literal, so a method carrying one + // always carries a literal table + assert($literalShift !== null); $operand = $opline->{$operandField}; assert($operand instanceof CData); $current = $operand->constant; assert(is_int($current)); - // znode_op.constant is a uint32_t holding a SIGNED opline-relative offset, so the - // literal has to stay within 2GB of the relocated opline. Request memory and the - // compiler arena are neighbours, but an opcache-shared body lives in an mmap'd - // region that can be arbitrarily far away - and a silently truncated offset would - // read whatever happens to sit at the wrapped address. - $relocated = self::asSignedOffset($current) + $shift; - if ($relocated < -0x80000000 || $relocated > 0x7FFFFFFF) { - throw new ClassSpecializationException( - 'Cannot un-share the opcodes of this method: its literals are ' - . abs($relocated) . ' bytes from the relocated opcode array, which does not ' - . 'fit the signed 32-bit offset an IS_CONST operand stores. This happens when ' - . 'the body is opcache-shared, because shared memory is too far from the ' - . 'request heap; substituting a builtin parameter type needs a body that is ' - . 'not in shared memory.', - ); - } - $operand->constant = $relocated & 0xFFFFFFFF; + // znode_op.constant is a uint32_t holding a SIGNED opline-relative offset: the + // opcodes moved by $opcodeShift and the literal it addressed moved by + // $literalShift, so their relative distance changed by the difference. The + // result is bounded by the combined block size, so it always fits 32 bits. + $operand->constant = (self::asSignedOffset($current) + $opcodeShift - $literalShift) & 0xFFFFFFFF; } } - $opArrayCopy->opcodes = Core::cast('zend_op *', Core::addr($memory)); - assert(self::opcodeCopyResolvesIdentically($sourceOpcodes, $memory, $total, $opcodeSize)); + $opArrayCopy->opcodes = $copiedOpcodes; + assert(self::opcodeCopyResolvesIdentically($sourceOpArray, $opArrayCopy, $total, $opcodeSize)); - return $memory; + return $copiedOpcodes; } /** * Verifies that a relocated opcode block still means exactly what the source meant * - * Every IS_CONST operand must resolve to the same zval address it resolved to before the - * move, and every jump offset must still land inside the array. This runs under - * zend.assertions=1 and compiles out in production: a wrong relocation rule is the one - * mistake here that would otherwise surface as memory corruption at some later call rather - * than as a failure at specialization time. + * Every IS_CONST operand must resolve to the copied literal at the very index its source + * operand resolved to (and land zval-aligned inside the copied literal table), and every + * jump offset must still land inside the array. This runs under zend.assertions=1 and + * compiles out in production: a wrong relocation rule is the one mistake here that would + * otherwise surface as memory corruption at some later call rather than as a failure at + * specialization time. * - * @param \FFI\CData $sourceOpcodes - * @param \FFI\CData $copiedOpcodes + * @param \FFI\CData $sourceOpArray + * @param \FFI\CData $opArrayCopy */ private static function opcodeCopyResolvesIdentically( - object $sourceOpcodes, - object $copiedOpcodes, + object $sourceOpArray, + object $opArrayCopy, int $total, int $opcodeSize, ): bool { + $sourceOpcodes = $sourceOpArray->opcodes; + $copiedOpcodes = $opArrayCopy->opcodes; + $totalLiterals = $sourceOpArray->last_literal; + assert($sourceOpcodes instanceof CData && $copiedOpcodes instanceof CData && is_int($totalLiterals)); $sourceBase = Core::addressOf($sourceOpcodes); - $copyBase = Core::addressOf(Core::cast('zend_op *', Core::addr($copiedOpcodes))); + $copyBase = Core::addressOf($copiedOpcodes); + $zvalSize = Core::sizeOfType(zval::class); + + $sourceLiteralsBase = null; + $copyLiteralsBase = null; + if ($totalLiterals > 0) { + $sourceLiterals = $sourceOpArray->literals; + $copiedLiterals = $opArrayCopy->literals; + assert($sourceLiterals instanceof CData && $copiedLiterals instanceof CData); + $sourceLiteralsBase = Core::addressOf($sourceLiterals); + $copyLiteralsBase = Core::addressOf($copiedLiterals); + } for ($index = 0; $index < $total; $index++) { $sourceOpline = $sourceOpcodes[$index]; @@ -1420,10 +1535,19 @@ private static function opcodeCopyResolvesIdentically( $sourceConstant = $sourceOperand->constant; $copiedConstant = $copiedOperand->constant; assert(is_int($sourceConstant) && is_int($copiedConstant)); - // Both are unsigned views of a signed offset, so compare the resolved addresses - $sourceTarget = $sourceBase + $index * $opcodeSize + self::asSignedOffset($sourceConstant); - $copiedTarget = $copyBase + $index * $opcodeSize + self::asSignedOffset($copiedConstant); - if ($sourceTarget !== $copiedTarget) { + // An IS_CONST operand with no literal table to land in cannot be right + if ($sourceLiteralsBase === null || $copyLiteralsBase === null) { + return false; + } + // Both are unsigned views of a signed offset; the literals moved with the + // opcodes, so the resolved targets must sit at the SAME DELTA within their + // respective literal tables - and inside them, on a zval boundary + $sourceDelta = $sourceBase + $index * $opcodeSize + self::asSignedOffset($sourceConstant) - $sourceLiteralsBase; + $copiedDelta = $copyBase + $index * $opcodeSize + self::asSignedOffset($copiedConstant) - $copyLiteralsBase; + if ($sourceDelta !== $copiedDelta) { + return false; + } + if ($copiedDelta < 0 || $copiedDelta >= $totalLiterals * $zvalSize || $copiedDelta % $zvalSize !== 0) { return false; } } diff --git a/src/Reflection/FunctionBodySwap.php b/src/Reflection/FunctionBodySwap.php index 9c91372f..0c543e33 100644 --- a/src/Reflection/FunctionBodySwap.php +++ b/src/Reflection/FunctionBodySwap.php @@ -41,8 +41,11 @@ * defaults; the live per-entry table materializes lazily on the first * ZEND_BIND_STATIC, exactly like a plain compiled function. * - The previous body is destroyed with engine semantics (destroy_op_array) when - * the swap is committed - unless it lives in opcache shared memory, which is - * never freed. The entry keeps its owned reference on the function name; + * the swap is committed. A previous body living in opcache shared memory keeps + * its compiled arrays (SHM is never freed), but the per-entry resources the + * swap minted for it (heap run-time cache, statics defaults duplicate) are + * still released, so swaps stay memory-flat with shared-memory donors too. + * The entry keeps its owned reference on the function name; * everything exclusively owned by the old body (opcodes, literals, vars, * arg_info, static variables, heap run-time cache) is released, and bodies still * shared with someone else (a template op_array, a fake closure) survive through @@ -436,37 +439,45 @@ public static function destroyPreviousBody(object $previousBody, int $entryAddre { $previousFunction = ReflectionFunction::fromCData(Core::cast('zend_function *', Core::addr($previousBody))); $previousOpArray = $previousFunction->getOpArrayPointer(); - $refCountPointer = $previousOpArray->refcount; - if ($refCountPointer === null) { - // No refcount means an opcache-shared body: never destroyed (and the swap - // paths never destroy SHM bodies in the first place) - return; - } if (self::hasLiveFrame($entryAddress)) { // A frame of this very entry is still executing the previous opcodes (the - // function redefined itself, directly or through a callee): freeing them - // would pull memory out from under the running VM frame. The previous body - // stays allocated instead - bounded to one body per such in-flight + // function redefined itself, directly or through a callee): freeing them - + // or the run-time cache the frame reads its inline caches from - would pull + // memory out from under the running VM frame. The previous body stays + // allocated instead - bounded to one body per such in-flight // redefinition, see docs/hot-swap.md. return; } - // All bucket shares move to the new body: drop every previous share except the - // one that destroy_op_array below releases itself - $referenceCount = self::counterValue($refCountPointer); - if ($releasedShares > 1) { - assert($referenceCount >= $releasedShares); - $referenceCount = $referenceCount - ($releasedShares - 1); - $refCountPointer[0] = $referenceCount; + // A body without a refcount is opcache-shared: its compiled arrays live in + // shared memory and are never freed (destroy_op_array below returns before + // touching them), so it can never be the "last holder". The per-entry + // resources the swap minted for it - the HEAP_RT_CACHE run-time cache and a + // statics defaults duplicate - are ordinary request memory though, and are + // released below exactly like for a refcounted body, or repeated swaps whose + // donors were declared in a cached file leak one cache per swap (the + // fixed-donor plateau of issue #242 under opcache). + $refCountPointer = $previousOpArray->refcount; + $isLastHolder = false; + if ($refCountPointer !== null) { + // All bucket shares move to the new body: drop every previous share except + // the one that destroy_op_array below releases itself + $referenceCount = self::counterValue($refCountPointer); + if ($releasedShares > 1) { + assert($referenceCount >= $releasedShares); + $referenceCount = $referenceCount - ($releasedShares - 1); + $refCountPointer[0] = $referenceCount; + } + $isLastHolder = $referenceCount <= 1; } - $isLastHolder = $referenceCount <= 1; $rawOpArray = Core::cast('zend_op_array *', Core::addr($previousBody)); if ($isLastHolder) { // Frees the materialized live static-variables table (if any) and clears - // the map slot; with other holders alive the table must survive - fake - // closures over the old body still reference it through their map slots + // the map slot; with other holders alive (or unaccountable, as for a + // refcount-less body) the table must survive - fake closures over the old + // body still reference it through their map slots Core::call('zend_destroy_static_vars', $rawOpArray); } @@ -485,7 +496,11 @@ public static function destroyPreviousBody(object $previousBody, int $entryAddre } // The entry keeps the single owned reference on the name - the snapshot must - // not release it + // not release it. destroy_op_array releases the HEAP_RT_CACHE run-time cache + // (and would release the name) BEFORE its refcount check, then frees the body + // arrays for the last holder of a refcounted body and returns without touching + // the arrays of a refcount-less (opcache-shared) one - engine-exact semantics + // for both lifetime classes. $previousOpArray->function_name = null; Core::call('destroy_op_array', $rawOpArray); } diff --git a/src/Reflection/FunctionLikeInterface.php b/src/Reflection/FunctionLikeInterface.php index 7f1a5d1f..cd2058e9 100644 --- a/src/Reflection/FunctionLikeInterface.php +++ b/src/Reflection/FunctionLikeInterface.php @@ -51,4 +51,11 @@ public function getAddress(): int; public function getEntryPointer(): object; public function isUserDefined(): bool; + + /** + * Compiled-variable names by CV slot (see FunctionLikeTrait::getVariableNames()) + * + * @return array + */ + public function getVariableNames(): array; } diff --git a/src/Reflection/FunctionLikeTrait.php b/src/Reflection/FunctionLikeTrait.php index d9a21866..459e9df8 100644 --- a/src/Reflection/FunctionLikeTrait.php +++ b/src/Reflection/FunctionLikeTrait.php @@ -194,18 +194,7 @@ public function redefine(\Closure $newCode): void $closureEntry = ClosureEntry::fromCData(Core::cast('zend_closure *', $newCodeEntry)); $newFunction = $closureEntry->getRawFunction(); - $isSharedMemoryEntry = $this->isImmutable(); - if ($isSharedMemoryEntry) { - // Copy the entry out of SHM: the per-process bucket that publishes it is - // repointed at a writable container, the SHM original stays untouched - // (never written, never freed). A method entry lives inside the shared - // class entry, so the whole class is copied out and the swap targets the - // method entry of the writable copy. - $entryScope = $this->getCommonPointer()->scope; - $this->pointer = $entryScope !== null - ? $this->copyMethodOutOfSharedMemory($entryScope) - : $this->copyOutOfSharedMemory(); - } + $isSharedMemoryEntry = $this->copyEntryOutOfSharedMemory(); $entryFunction = ReflectionFunction::fromCData($this->pointer); $donorFunction = ReflectionFunction::fromCData(Core::addr($newFunction)); @@ -234,6 +223,41 @@ public function redefine(\Closure $newCode): void } } + /** + * Copies an opcache-shared (ZEND_ACC_IMMUTABLE) entry out of shared memory and + * rebinds this reflection to the writable entry now published in its table + * + * A no-op for entries that are not opcache-shared. For a global function the + * per-process function-table bucket is repointed at a writable container; for a + * method the whole declaring class is copied out (a method table lives inside the + * class entry) and the reflection rebinds to the method entry of the writable + * copy. In both cases the SHM original stays untouched - never written, never + * freed. See docs/hot-swap.md for the support matrix and the copy-out caveats. + * + * @return bool True when the entry was opcache-shared and is now copied out + * + * @throws SharedMemoryException When the entry cannot be copied out of shared memory + * + * @internal called by redefine(); shared with the cache-image bridge (CacheImageSync) + */ + public function copyEntryOutOfSharedMemory(): bool + { + if (!$this->isImmutable()) { + return false; + } + // Copy the entry out of SHM: the per-process bucket that publishes it is + // repointed at a writable container, the SHM original stays untouched + // (never written, never freed). A method entry lives inside the shared + // class entry, so the whole class is copied out and the swap targets the + // method entry of the writable copy. + $entryScope = $this->getCommonPointer()->scope; + $this->pointer = $entryScope !== null + ? $this->copyMethodOutOfSharedMemory($entryScope) + : $this->copyOutOfSharedMemory(); + + return true; + } + /** * Copies this opcache-shared global function out of shared memory into a writable * container and repoints its per-process function-table bucket at the copy diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php index 447ea918..c457ba79 100644 --- a/src/Reflection/ReflectionClass.php +++ b/src/Reflection/ReflectionClass.php @@ -2645,7 +2645,7 @@ public function setCreateObjectHandler(Closure $handler): CreateObjectHook if ($this->isInternal()) { trigger_error('Create object handler is available for user-defined classes only', E_USER_ERROR); } - $this->assertNotLazyLinkingCopy('create_object'); + $this->keepLazyLinkingCopyProcessLocal('create_object'); self::getObjectHandlers($this->pointer); $hook = new CreateObjectHook($handler, $this->pointer); @@ -2668,7 +2668,7 @@ public function setCreateObjectHandler(Closure $handler): CreateObjectHook */ public function setGetIteratorHandler(Closure $handler): GetIteratorHook { - $this->assertNotLazyLinkingCopy('get_iterator'); + $this->keepLazyLinkingCopyProcessLocal('get_iterator'); $hook = new GetIteratorHook($handler, $this->pointer); $hook->install(); @@ -2687,7 +2687,7 @@ public function setInterfaceGetsImplementedHandler(Closure $handler): InterfaceG } // An interface entry can itself be the lazy temporary while it links against // its own parent interfaces - $this->assertNotLazyLinkingCopy('interface_gets_implemented'); + $this->keepLazyLinkingCopyProcessLocal('interface_gets_implemented'); $hook = new InterfaceGetsImplementedHook($handler, $this->pointer); $hook->install(); @@ -2711,7 +2711,7 @@ public function setInterfaceGetsImplementedHandler(Closure $handler): InterfaceG */ private function installObjectHook(string $hookClass, Closure $handler): AbstractHook { - $this->assertNotLazyLinkingCopy($hookClass); + $this->keepLazyLinkingCopyProcessLocal($hookClass); $handlers = self::getObjectHandlers($this->pointer); $hook = new $hookClass($handler, $handlers); @@ -2721,23 +2721,36 @@ private function installObjectHook(string $hookClass, Closure $handler): Abstrac } /** - * Rejects handler installation on a temporary lazy-linking class copy (issue #238) + * Makes handler installation on a temporary lazy-linking class copy stick (issue #241) * - * The handlers block is keyed to this entry's address; the temporary is discarded - * when opcache's inheritance cache persists the linked class, so the installation - * would silently do nothing. Probe with isLazyLinkingCopy() before installing from - * an interface_gets_implemented hook. Issue #241 (declining the inheritance cache - * for hooked classes) is the path to making this installation actually work. + * The handlers block is keyed to this entry's address; without intervention the + * temporary is discarded as soon as opcache's inheritance cache persists the linked + * class, silently losing every installed handler (issue #238). So the entry is + * recorded in the Core decline set: when its linking completes, the intercepted + * zend_inheritance_cache_add answers NULL (the engine's ordinary "not cached" + * outcome) and the temporary stays in the class table as a process-local class - + * the handlers remain valid for the request, the class is simply re-linked per + * process instead of reused from the cache, and no process-local trampoline + * address ever reaches shared memory. + * + * When the interception is unavailable (engine definitions generated before the + * zend_inheritance_cache_add symbol was exported), the loud guard of issue #238 + * remains: the installation throws instead of being silently lost. Probe with + * isLazyLinkingCopy() before installing from an interface_gets_implemented hook. * * @param string $handlerName Handler field or hook class named in the diagnostic * - * @throws SharedMemoryException + * @throws SharedMemoryException When declining is unavailable in this process */ - private function assertNotLazyLinkingCopy(string $handlerName): void + private function keepLazyLinkingCopyProcessLocal(string $handlerName): void { - if ($this->isLazyLinkingCopy()) { + if (!$this->isLazyLinkingCopy()) { + return; + } + if (!Core::canDeclineInheritanceCachePublication()) { throw SharedMemoryException::handlerInstallationDuringLazyLinking($this->getName(), $handlerName); } + Core::declineInheritanceCachePublication(Core::addressOf($this->pointer)); } /** diff --git a/src/System/Hook/InheritanceCacheAddHook.php b/src/System/Hook/InheritanceCacheAddHook.php new file mode 100644 index 00000000..b481e54c --- /dev/null +++ b/src/System/Hook/InheritanceCacheAddHook.php @@ -0,0 +1,103 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\System\Hook; + +use FFI\CData; +use ZEngine\Core; +use ZEngine\Hook\AbstractHook; + +/** + * Interceptor for the `zend_inheritance_cache_add` engine global callback (issue #241) + * + * Opcache publishes this pointer so `zend_do_link_class()`/`zend_try_early_bind()` can + * persist a freshly linked class into the shared-memory inheritance cache. The engine + * links such a class on a temporary mutable copy (`zend_lazy_class_load()`); when the + * publication succeeds, the caller swaps the class-table bucket to the returned + * shared-memory entry and the temporary is discarded - together with every z-engine + * handler keyed to the temporary's address (issue #238). + * + * This hook makes handler installation during lazy linking stick: for a class entry + * recorded in the Core decline set it returns NULL, which the engine treats as an + * ordinary "not cached" outcome (opcache itself returns NULL when SHM is full or a + * restart is pending) - the temporary stays in the class table as a process-local, + * request-lifetime class, so the address-keyed handlers remain valid and no + * process-local trampoline address is ever published into shared memory. Every other + * class is delegated to the saved opcache callback unchanged. + * + * The callback runs DURING class linking - and, through compile-time early binding, + * possibly while CG(in_compilation) is set, where the engine promotes EVERY thrown + * exception to an immediate fatal error before any catch runs (see AstProcessHook). + * So handle() is deliberately minimal AND throw-free on its hot path: no file + * inclusion, no engine mutation, and in particular no Core::addressOf()/cast(), + * whose array-decay probe throws-and-catches an FFI\Exception per call. Any + * unexpected internal failure degrades to declining the publication, which is + * always safe (the class simply stays process-local and is re-linked per request). + */ +final class InheritanceCacheAddHook extends AbstractHook +{ + protected const string HOOK_FIELD = 'zend_inheritance_cache_add'; + + /** + * zend_class_entry *(*zend_inheritance_cache_add)( + * zend_class_entry *ce, zend_class_entry *proto, zend_class_entry *parent, + * zend_class_entry **traits_and_interfaces, HashTable *dependencies); + * + * `ce` is the temporary linked copy (ZEND_ACC_LINKED set, ZEND_ACC_IMMUTABLE + * clear - see the asserts in opcache's zend_accel_inheritance_cache_add) and + * `proto` is the shared-memory original the temporary was loaded from, so the + * decline set is keyed by the address of `ce`: that is exactly the entry an + * interface_gets_implemented hook observed and installed handlers on. + * + * The user handler is the decline predicate `function (int $ceAddress): bool` + * (Core::takeInheritanceCacheDecline()); it must not throw and must not touch + * engine state. + * + * @inheritDoc + * @return CData|null zend_class_entry* of the published SHM entry, or null when + * the class was not cached (declined or refused by opcache) + */ + #[\Override] + public function handle(...$rawArguments): ?CData + { + [$classEntry, $prototype, $parent, $traitsAndInterfaces, $dependencies] = $rawArguments; + + try { + assert($classEntry instanceof CData); + // Throw-free pointer identity: a direct reinterpreting cast, unlike + // Core::addressOf(), whose array-decay probe throws internally - fatal + // when this callback fires during compile-time early binding + if (($this->userHandler)(Core::pointerAddressOf($classEntry)) === true) { + // Declined: the engine keeps the process-local temporary in the class table + return null; + } + if (!$this->hasOriginalHandler()) { + return null; + } + $publishedEntry = ($this->getOriginalCallable())( + $classEntry, + $prototype, + $parent, + $traitsAndInterfaces, + $dependencies, + ); + assert($publishedEntry === null || $publishedEntry instanceof CData); + + return $publishedEntry; + } catch (\Throwable) { + // Nothing may escape an engine callback that runs mid-linking (issue #50); + // declining the publication is the always-safe degradation + return null; + } + } +} diff --git a/tests/HotSwap/CacheImageSyncTest.php b/tests/HotSwap/CacheImageSyncTest.php new file mode 100644 index 00000000..51a1efb9 --- /dev/null +++ b/tests/HotSwap/CacheImageSyncTest.php @@ -0,0 +1,161 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\HotSwap; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\OpCache\FileCacheFixture; +use ZEngine\OpCache\PayloadRelocator; + +/** + * The file-cache -> runtime bridge end to end (issue #122): a patched + * ReflectionOpcacheFile image is applied to the functions and classes ALREADY + * LOADED in a live process, without re-including the script. + * + * Each probe runs in a child process (the bridge mutates executor tables and + * the child's clean exit doubles as the shutdown check), with the cache binary + * compiled into a per-test directory owned by this class: + * + * - plain child (opcache off): unchanged-noop diff, patched apply, live + * dispatch of the patched bodies, idempotent re-diff, single-use sync; + * - shared-memory child (opcache on): the same loop against immutable + * entries, proving the copy-out path and the untouched SHM originals; + * - refusal child: never-loaded images report not-loaded entries instead of + * crashing, unchanged enums pass, changed enum methods throw loudly. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +class CacheImageSyncTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testAppliesPatchedImageToLoadedEntries(): void + { + [$exitCode, $stdout, $report] = $this->runImageSyncChild(__DIR__ . '/scripts/cache-image-sync.php'); + + self::assertSame(0, $exitCode, "Image-sync child exited with code {$exitCode}\n{$report}"); + self::assertStringContainsString('noop-diff: ok', $stdout, $report); + self::assertStringContainsString('patched-apply: ok', $stdout, $report); + self::assertStringContainsString('live-dispatch: ok', $stdout, $report); + self::assertStringContainsString('idempotency: ok', $stdout, $report); + self::assertStringContainsString('IMAGE SYNC OK', $stdout, $report); + } + + public function testAppliesPatchedImageToSharedMemoryEntries(): void + { + [$exitCode, $stdout, $report] = $this->runImageSyncChild( + __DIR__ . '/scripts/cache-image-sync-shm.php', + [ + '-d', 'opcache.enable=1', + '-d', 'opcache.enable_cli=1', + // A freshly checked out fixture is younger than the default + // 2-second update protection and would silently not be cached + '-d', 'opcache.file_update_protection=0', + ], + ); + + self::assertSame(0, $exitCode, "SHM image-sync child exited with code {$exitCode}\n{$report}"); + self::assertStringContainsString('shm-noop-diff: ok', $stdout, $report); + self::assertStringContainsString('shm-apply: ok', $stdout, $report); + self::assertStringContainsString('shm-dispatch: ok', $stdout, $report); + self::assertStringContainsString('shm-copy-out: ok', $stdout, $report); + self::assertStringContainsString('shm-idempotency: ok', $stdout, $report); + self::assertStringContainsString('IMAGE SYNC SHM OK', $stdout, $report); + } + + public function testReportsNotLoadedEntriesAndRefusesEnumsLoudly(): void + { + [$exitCode, $stdout, $report] = $this->runImageSyncChild(__DIR__ . '/scripts/cache-image-sync-refusals.php'); + + self::assertSame(0, $exitCode, "Refusal child exited with code {$exitCode}\n{$report}"); + self::assertStringContainsString('not-loaded-report: ok', $stdout, $report); + self::assertStringContainsString('unchanged-enum: ok', $stdout, $report); + self::assertStringContainsString('refused-enum: ok', $stdout, $report); + self::assertStringContainsString('IMAGE SYNC REFUSALS OK', $stdout, $report); + } + + /** + * Runs one bridge probe in a child process with a fresh cache directory + * + * @param list $extraOptions Additional `php -d` options (the opcache leg) + * + * @return array{int, string, string} Exit code, stdout and a stdout+stderr report + */ + private function runImageSyncChild(string $scriptPath, array $extraOptions = []): array + { + if (!extension_loaded('Zend OPcache')) { + self::markTestSkipped('Zend OPcache extension is not loaded'); + } + self::$cacheDir = sys_get_temp_dir() . '/zengine-image-sync-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir(self::$cacheDir, 0o755, true), 'Cannot create the file-cache directory'); + + $command = [ + PHP_BINARY, + '-d', 'ffi.enable=1', + '-d', 'zend.assertions=1', + '-d', 'assert.exception=1', + '-d', 'display_errors=on', + '-d', 'error_reporting=-1', + '-d', 'memory_limit=512M', + // The JIT rewrites the executor internals z-engine hooks into (AGENTS.md) + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + // Hermeticity: the bridge diffs an image against a live entry compiled at + // the SAME optimization level, and the plain/refusal legs deliberately + // pair an optimizer-off image (compiled by BinaryCacheFile::compile with + // opcache.optimization_level=0) with an unoptimized live side loaded from + // source. The opcache-runner CI job sets opcache.enable_cli=1 in php.ini, + // which would otherwise leak into this child and optimize its live-side + // require - producing a genuinely different (spuriously non-empty) diff. + // Pin CLI opcache off here so the plain leg is deterministic whatever the + // runner's php.ini says; the shared-memory leg re-enables it via + // $extraOptions, which come last and win (php applies -d in order). + '-d', 'opcache.enable_cli=0', + ...$extraOptions, + $scriptPath, + self::$cacheDir, + ]; + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process, 'Unable to spawn the image-sync child process'); + + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + $report = "STDOUT:\n{$stdout}\nSTDERR:\n{$stderr}"; + if ($exitCode === 2) { + // Never a silent pass: the branch under test was not exercised at all + self::markTestSkipped("Opcache could not be activated in the child process\n{$report}"); + } + + return [$exitCode, $stdout, $report]; + } +} diff --git a/tests/HotSwap/OpcacheSupportMatrixTest.php b/tests/HotSwap/OpcacheSupportMatrixTest.php index b285879e..e1d68d17 100644 --- a/tests/HotSwap/OpcacheSupportMatrixTest.php +++ b/tests/HotSwap/OpcacheSupportMatrixTest.php @@ -28,6 +28,8 @@ * copied out of shared memory and the mutation applies to the writable copy, * while the shared-memory original stays byte-for-byte untouched * - runtime-declared classes keep the full mutation surface under opcache + * - same-file callers observe a redefine through the repointed bucket, and the + * optimizer-inlined trivial-constant call sites stay baked (issue #242) * * The child exit code doubles as the shutdown check of issue #41, whose original * symptom was a zend_function_dtor() assertion failure and a SIGABRT while the @@ -38,7 +40,18 @@ class OpcacheSupportMatrixTest extends TestCase { public function testSharedMemorySupportMatrix(): void { - [$exitCode, $stdout, $report] = $this->runOpcacheChild(__DIR__ . '/scripts/opcache-matrix.php'); + [$exitCode, $stdout, $report] = $this->runOpcacheChild( + __DIR__ . '/scripts/opcache-matrix.php', + [ + // The same-file legs (issue #242) need the matrix script itself in shared + // memory: the default opcache.file_update_protection=2 refuses files + // modified less than 2s before the request (a fresh checkout) + '-d', 'opcache.file_update_protection=0', + // The inlined-call-site leg asserts the behavior of the default optimizer + // pipeline (zend_try_inline_call runs in pass 4): pin it against php.ini + '-d', 'opcache.optimization_level=0x7FFEBFFF', + ], + ); self::assertSame(0, $exitCode, "Opcache matrix child exited with code {$exitCode}\n{$report}"); self::assertStringContainsString('function-copy-out: ok', $stdout, $report); @@ -47,17 +60,20 @@ public function testSharedMemorySupportMatrix(): void self::assertStringContainsString('hot-swap: ok', $stdout, $report); self::assertStringContainsString('runtime-class-swap: ok', $stdout, $report); self::assertStringContainsString('static-vars-live-table: ok', $stdout, $report); + self::assertStringContainsString('same-file-redefine: ok', $stdout, $report); + self::assertStringContainsString('inlined-call-site-limitation: ok', $stdout, $report); self::assertStringContainsString('MATRIX OK', $stdout, $report); } /** - * The loud guard of issue #238: handlers installed from an interface_gets_implemented - * hook target the temporary class entry opcache links classes on, and the temporary is - * discarded once the inheritance cache persists the linked result - so the installation - * must throw instead of being silently lost (issue #241 tracks making it stick by - * declining the inheritance cache for hooked classes) + * The fix of issue #238 (via issue #241): handlers installed from an + * interface_gets_implemented hook target the temporary class entry opcache links + * classes on. z-engine records that entry and declines its publication into the + * inheritance cache (the intercepted zend_inheritance_cache_add answers NULL), so + * the temporary stays process-local, the handlers survive linking and actually fire + * - while an untouched sibling class is still published into the cache unchanged */ - public function testHandlerInstallationDuringLazyLinkingIsRejected(): void + public function testHandlersInstalledDuringLazyLinkingSurviveViaCacheDecline(): void { [$exitCode, $stdout, $report] = $this->runOpcacheChild( __DIR__ . '/scripts/opcache-interface-hook.php', @@ -68,7 +84,8 @@ public function testHandlerInstallationDuringLazyLinkingIsRejected(): void ); self::assertSame(0, $exitCode, "Interface-hook child exited with code {$exitCode}\n{$report}"); - self::assertStringContainsString('lazy-linking-guard: ok', $stdout, $report); + self::assertStringContainsString('lazy-linking-handlers: ok', $stdout, $report); + self::assertStringContainsString('sibling-cache-reuse: ok', $stdout, $report); self::assertStringContainsString('INTERFACE HOOK OK', $stdout, $report); } diff --git a/tests/HotSwap/RedefineLeakPlateauTest.php b/tests/HotSwap/RedefineLeakPlateauTest.php index a688cf07..7c822f17 100644 --- a/tests/HotSwap/RedefineLeakPlateauTest.php +++ b/tests/HotSwap/RedefineLeakPlateauTest.php @@ -29,6 +29,11 @@ * * Before the fix the function/method series grew by a full function body per cycle * over the baseline; with the previous body destroyed the redefine cost is zero. + * + * The child pins opcache ON (issue #242): the measured functions live in the same + * cached script as their dispatch call sites, so the run doubles as the regression + * test for the first-redefine-under-opcache failure, and the fixed-donor series + * guards the per-swap run-time-cache release for shared-memory donor bodies. */ class RedefineLeakPlateauTest extends TestCase { @@ -57,11 +62,20 @@ public function testThousandRedefineCyclesAreMemoryFlat(): void '-d', 'display_errors=on', '-d', 'error_reporting=-1', '-d', 'memory_limit=512M', - // Pinned off so the measurement is hermetic whatever php.ini says (an - // opcache-active runner, or Ubuntu's PHP 8.5 default opcache.enable_cli=On). - // TODO(#242): with opcache active in this child the very first redefine() - // does not take effect ("warm-up dispatch failed") - unpin once fixed - '-d', 'opcache.enable_cli=0', + // Pinned ON (never inherited from php.ini) so the child deterministically + // exercises the issue #242 regression shape: the measured functions are + // declared in the same cached script as their dispatch call sites, and the + // first redefine() runs the shared-memory copy-out path. Where the opcache + // extension is not loaded these switches are inert and the child measures + // the plain in-place path, as before. + '-d', 'opcache.enable=1', + '-d', 'opcache.enable_cli=1', + // The JIT rewrites the executor internals z-engine hooks into (AGENTS.md) + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + // A fresh checkout must still publish the script from shared memory (the + // default opcache.file_update_protection=2 refuses files modified <2s ago) + '-d', 'opcache.file_update_protection=0', __DIR__ . '/scripts/redefine-plateau.php', ]; $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); diff --git a/tests/HotSwap/scripts/cache-image-sync-refusals.php b/tests/HotSwap/scripts/cache-image-sync-refusals.php new file mode 100644 index 00000000..15eec141 --- /dev/null +++ b/tests/HotSwap/scripts/cache-image-sync-refusals.php @@ -0,0 +1,126 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * CacheImageSync refusal and not-loaded reporting probe (no opcache in this + * process): + * + * - an image whose script was never loaded here reports every entry as not + * loaded and applies as a loud no-op (never a crash, never an invention); + * - an UNCHANGED enum in a loaded image is not an operation and passes; + * - a CHANGED enum method is refused loudly (throw-or-work, never silent), + * and the live enum keeps executing its old body. + * + * argv: [1] = file-cache directory to compile the fixture into (parent-owned) + */ + +use ZEngine\Core; +use ZEngine\HotSwap\CacheImageSync; +use ZEngine\HotSwap\HotSwapException; +use ZEngine\OpCache\BinaryCacheFile; + +require __DIR__ . '/../../../vendor/autoload.php'; + +Core::init(); + +$fail = static function (string $message): never { + fwrite(STDERR, "{$message}\n"); + exit(1); +}; + +$cacheDir = $argv[1] ?? ''; +if ($cacheDir === '') { + $fail('cache directory argument missing'); +} +$fixture = realpath(__DIR__ . '/image-sync-enum-fixture.php'); +if ($fixture === false) { + $fail('enum fixture not found'); +} + +$file = BinaryCacheFile::compile($fixture, $cacheDir, PHP_BINARY, [ + 'opcache.optimization_level=0', + 'opcache.file_update_protection=0', +]); +$image = $file->getReflection(); + +// 1. Nothing of the image is loaded yet: everything lands in the not-loaded +// buckets and apply() is an explicit no-op report, not a crash +$orphan = CacheImageSync::prepare($image); +if (!$orphan->isEmpty()) { + $fail('image of a never-loaded script must diff as empty'); +} +$orphanReport = $orphan->apply(); +if (!$orphanReport->isNoOp()) { + $fail('applying an image of a never-loaded script must be a no-op'); +} +if ($orphanReport->notLoadedFunctions !== ['zengine_image_sync_enum_side']) { + $fail('the image-only function is not reported as not loaded'); +} +if ($orphanReport->notLoadedClasses !== ['zengineimagesyncchannel']) { + $fail('the image-only enum is not reported as not loaded'); +} +echo "not-loaded-report: ok\n"; + +// 2. Loaded and untouched: an enum in the image is not an operation, so +// nothing is refused and the apply stays a no-op +require $fixture; +$unchanged = CacheImageSync::prepare($image); +if (!$unchanged->isEmpty()) { + $fail('untouched enum image did not diff as empty'); +} +if (!$unchanged->apply()->isNoOp()) { + $fail('untouched enum image apply was not a no-op'); +} +echo "unchanged-enum: ok\n"; + +// 3. A patched enum METHOD is a refused operation: prepare() exposes the +// refusal, apply() throws it before touching anything +foreach ($image->getClasses()['zengineimagesyncchannel']->getDeclaredMethods()['describe']->getLiterals() as $literal) { + $value = null; + $literal->getNativeValue($value); + if ($value === 'channel-') { + $literal->setNativeValue('patched-'); + } +} +$refused = CacheImageSync::prepare($image); +if ($refused->isEmpty()) { + $fail('a changed enum method must not diff as empty'); +} +$reasons = $refused->getRefusalReasons(); +if ($reasons === [] || !str_contains($reasons[0], 'ZEngineImageSyncChannel')) { + $fail('the refusal reason does not name the enum'); +} +try { + $refused->apply(); + $fail('applying a changed enum method must throw'); +} catch (HotSwapException $exception) { + if (!str_contains($exception->getMessage(), 'enums are not supported')) { + $fail('unexpected refusal message: ' . $exception->getMessage()); + } +} +// The live enum still executes its previous body (runtime-name dispatch) +$callCase = static function (string $enum, string $method) use ($fail): string { + $case = constant("{$enum}::Stable"); + if (!is_object($case)) { + $fail("{$enum}::Stable is not a case object"); + } + $result = $case->{$method}(); + + return is_string($result) ? $result : ''; +}; +if ($callCase('ZEngineImageSyncChannel', 'describe') !== 'channel-stable') { + $fail('the refused enum body must stay untouched'); +} +echo "refused-enum: ok\n"; + +echo "IMAGE SYNC REFUSALS OK\n"; diff --git a/tests/HotSwap/scripts/cache-image-sync-shm.php b/tests/HotSwap/scripts/cache-image-sync-shm.php new file mode 100644 index 00000000..842b2e4f --- /dev/null +++ b/tests/HotSwap/scripts/cache-image-sync-shm.php @@ -0,0 +1,171 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * CacheImageSync against opcache SHARED MEMORY: this process runs with opcache + * enabled, so the fixture's entries are published immutable from shared + * memory. Applying a patched image must copy the targets out of SHM (the + * documented copy-out paths), swap the writable copies and leave the shared + * originals byte-for-byte untouched. + * + * argv: [1] = file-cache directory to compile the fixture into (parent-owned) + */ + +use FFI\CData; +use ZEngine\Core; +use ZEngine\HotSwap\CacheImageSync; +use ZEngine\OpCache\BinaryCacheFile; +use ZEngine\Reflection\ReflectionClass; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Reflection\ReflectionMethod; + +require __DIR__ . '/../../../vendor/autoload.php'; + +if (!function_exists('opcache_get_status') || opcache_get_status(false) === false) { + fwrite(STDERR, "opcache is not active in the child process\n"); + exit(2); +} + +Core::init(); + +$fail = static function (string $message): never { + fwrite(STDERR, "{$message}\n"); + exit(1); +}; + +$cacheDir = $argv[1] ?? ''; +if ($cacheDir === '') { + $fail('cache directory argument missing'); +} +$fixture = realpath(__DIR__ . '/../../OpCache/fixtures/answer.php'); +if ($fixture === false) { + $fail('fixture not found'); +} + +// Default optimization on BOTH sides: the image compile child and this +// process's own require run the same opcache pipeline, so the untouched +// bodies must diff as equal +$file = BinaryCacheFile::compile($fixture, $cacheDir, PHP_BINARY, [ + 'opcache.file_update_protection=0', +]); +require $fixture; + +$liveFunction = new ReflectionFunction('zengine_bin_answer'); +if (!$liveFunction->isImmutable()) { + // Not published from shared memory: the copy-out branch under test would + // silently not be exercised + fwrite(STDERR, "zengine_bin_answer is not an immutable (shared-memory) function\n"); + exit(2); +} +$classValue = Core::$executor->classTable->find('zenginebinsubject'); +if ($classValue === null) { + $fail('fixture class is not published'); +} +$sharedEntry = $classValue->getRawClass(); +$sharedClass = ReflectionClass::fromCData($sharedEntry); +if (!$sharedClass->isImmutable()) { + fwrite(STDERR, "ZEngineBinSubject is not an immutable (shared-memory) class entry\n"); + exit(2); +} +$sharedMethodTable = $sharedEntry->function_table; +assert($sharedMethodTable instanceof CData); +$sharedFlagsBefore = $sharedEntry->ce_flags; +$sharedMethodsBefore = $sharedMethodTable->nNumOfElements; + +$image = $file->getReflection(); + +// 1. The untouched image diffs as equal against the SHM-published bodies +$untouched = CacheImageSync::prepare($image); +if (!$untouched->isEmpty()) { + $fail('untouched image did not diff as empty against shared memory'); +} +echo "shm-noop-diff: ok\n"; + +// 2. Patch and apply: the bridge copies the targets out of shared memory +$patchLiteral = static function (ReflectionFunction|ReflectionMethod $function, int|string $from, int|string $to): void { + foreach ($function->getLiterals() as $literal) { + $value = null; + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + } + } +}; +$patchLiteral($image->getFunctions()['zengine_bin_answer'], 41, 42); +$patchLiteral($image->getClasses()['zenginebinsubject']->getDeclaredMethods()['describe'], 1, 3); + +$report = CacheImageSync::prepare($image)->apply(); +if ($report->appliedFunctions !== ['zengine_bin_answer']) { + $fail('applied function report is wrong: ' . json_encode($report->appliedFunctions)); +} +if ($report->appliedMethods !== ['zenginebinsubject::describe']) { + $fail('applied method report is wrong: ' . json_encode($report->appliedMethods)); +} +echo "shm-apply: ok\n"; + +// 3. Live dispatch through runtime names executes the patched bodies +$callIt = static function (string $name) use ($fail): mixed { + if (!is_callable($name)) { + $fail("{$name} is not callable"); + } + + return $name(); +}; +$callStatic = static function (string $class, string $method, int $argument) use ($fail): string { + $callable = [$class, $method]; + if (!is_callable($callable)) { + $fail("{$class}::{$method} is not callable"); + } + $result = $callable($argument); + + return is_string($result) ? $result : ''; +}; +if ($callIt('zengine_bin_answer') !== 42) { + $fail('patched function body is not live'); +} +if ($callStatic('ZEngineBinSubject', 'describe', 0) !== 'stablestablestable') { + $fail('patched method body is not live'); +} +echo "shm-dispatch: ok\n"; + +// 4. The published entries are per-process copies now, and the shared-memory +// originals were neither written nor unpublished-and-freed +if ((new ReflectionFunction('zengine_bin_answer'))->isImmutable()) { + $fail('the swapped function entry is still marked immutable'); +} +$publishedValue = Core::$executor->classTable->find('zenginebinsubject'); +if ($publishedValue === null) { + $fail('the class lost its class-table bucket'); +} +$publishedEntry = $publishedValue->getRawClass(); +if (Core::addressOf($publishedEntry) === Core::addressOf($sharedEntry)) { + $fail('the class is still published from shared memory'); +} +if (ReflectionClass::fromCData($publishedEntry)->isImmutable()) { + $fail('the class copy is still marked immutable'); +} +if ($sharedEntry->ce_flags !== $sharedFlagsBefore) { + $fail('shared-memory ce_flags changed'); +} +if ($sharedMethodTable->nNumOfElements !== $sharedMethodsBefore) { + $fail('shared-memory method table changed'); +} +echo "shm-copy-out: ok\n"; + +// 5. Idempotency holds against the copied-out entries as well +if (!CacheImageSync::prepare($image)->isEmpty()) { + $fail('re-diff after apply is not empty'); +} +echo "shm-idempotency: ok\n"; + +echo "IMAGE SYNC SHM OK\n"; diff --git a/tests/HotSwap/scripts/cache-image-sync.php b/tests/HotSwap/scripts/cache-image-sync.php new file mode 100644 index 00000000..c7dba0be --- /dev/null +++ b/tests/HotSwap/scripts/cache-image-sync.php @@ -0,0 +1,158 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +/** + * CacheImageSync end-to-end probe WITHOUT opcache in this process: the fixture + * is loaded from source, the cache binary is compiled by a child with the + * optimizer off (so the compiled bodies provably match the source compile), + * then the image is patched and applied to the ALREADY-LOADED entries. + * + * argv: [1] = file-cache directory to compile the fixture into (parent-owned) + */ + +use ZEngine\Core; +use ZEngine\HotSwap\CacheImageSync; +use ZEngine\HotSwap\HotSwapException; +use ZEngine\OpCache\BinaryCacheFile; +use ZEngine\Reflection\ReflectionFunction; +use ZEngine\Reflection\ReflectionMethod; + +require __DIR__ . '/../../../vendor/autoload.php'; + +Core::init(); + +$fail = static function (string $message): never { + fwrite(STDERR, "{$message}\n"); + exit(1); +}; + +$cacheDir = $argv[1] ?? ''; +if ($cacheDir === '') { + $fail('cache directory argument missing'); +} +$fixture = realpath(__DIR__ . '/../../OpCache/fixtures/answer.php'); +if ($fixture === false) { + $fail('fixture not found'); +} + +// The optimizer is off for the image compile: this process loads the fixture +// from source (no opcache), and only unoptimized bodies are comparable with a +// plain source compile - which is exactly what the diff must report as equal +$file = BinaryCacheFile::compile($fixture, $cacheDir, PHP_BINARY, [ + 'opcache.optimization_level=0', + 'opcache.file_update_protection=0', +]); +require $fixture; + +$image = $file->getReflection(); + +// 1. An untouched image diffs as all-unchanged: applying is a loud no-op +$untouched = CacheImageSync::prepare($image); +if (!$untouched->isEmpty()) { + $fail('untouched image did not diff as empty'); +} +$noopReport = $untouched->apply(); +if (!$noopReport->isNoOp()) { + $fail('untouched image apply was not a no-op'); +} +if (!in_array('zengine_bin_answer', $noopReport->unchangedFunctions, true)) { + $fail('unchanged function is not reported'); +} +if (!in_array('zenginebinsubject::describe', $noopReport->unchangedMethods, true)) { + $fail('unchanged method is not reported'); +} +echo "noop-diff: ok\n"; + +// 2. Patch the image: a long literal, a size-changing string literal and a +// method literal (the method also carries statics and try/catch) +$patchLiteral = static function (ReflectionFunction|ReflectionMethod $function, int|string $from, int|string $to): void { + foreach ($function->getLiterals() as $literal) { + $value = null; + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + } + } +}; +$patchLiteral($image->getFunctions()['zengine_bin_answer'], 41, 42); +$patchLiteral($image->getFunctions()['zengine_bin_greeting'], 'hello', 'patched-hello'); +$patchLiteral($image->getClasses()['zenginebinsubject']->getDeclaredMethods()['describe'], 1, 3); + +$sync = CacheImageSync::prepare($image); +if ($sync->getChangedFunctions() !== ['zengine_bin_answer', 'zengine_bin_greeting']) { + $fail('changed function set is wrong: ' . json_encode($sync->getChangedFunctions())); +} +if ($sync->getChangedMethods() !== ['zenginebinsubject' => ['describe']]) { + $fail('changed method set is wrong: ' . json_encode($sync->getChangedMethods())); +} +$report = $sync->apply(); +if ($report->appliedFunctions !== ['zengine_bin_answer', 'zengine_bin_greeting']) { + $fail('applied function report is wrong'); +} +if ($report->appliedMethods !== ['zenginebinsubject::describe']) { + $fail('applied method report is wrong'); +} +if ($report->notLoadedFunctions !== [] || $report->notLoadedClasses !== [] || $report->notLoadedMethods !== []) { + $fail('nothing in this image should be reported as not loaded'); +} +echo "patched-apply: ok\n"; + +// 3. The LIVE, already-loaded entries now execute the patched bodies - no +// re-include happened. Runtime-name dispatch keeps every call site free of +// compile-time resolution and of statically-known results. +$callIt = static function (string $name) use ($fail): mixed { + if (!is_callable($name)) { + $fail("{$name} is not callable"); + } + + return $name(); +}; +$callStatic = static function (string $class, string $method, int $argument) use ($fail): string { + $callable = [$class, $method]; + if (!is_callable($callable)) { + $fail("{$class}::{$method} is not callable"); + } + $result = $callable($argument); + + return is_string($result) ? $result : ''; +}; +if ($callIt('zengine_bin_answer') !== 42) { + $fail('patched function body is not live'); +} +if ($callIt('zengine_bin_greeting') !== 'patched-hello') { + $fail('patched string-literal body is not live'); +} +if ($callStatic('ZEngineBinSubject', 'describe', 0) !== 'stablestablestable') { + $fail('patched method body is not live'); +} +// The method's static counter still works across calls on the swapped body +$callStatic('ZEngineBinSubject', 'describe', 0); +echo "live-dispatch: ok\n"; + +// 4. Idempotency: a fresh diff of the same image against the synced process is +// empty; re-applying the consumed sync is refused loudly +$again = CacheImageSync::prepare($image); +if (!$again->isEmpty()) { + $fail('re-diff after apply is not empty'); +} +try { + $sync->apply(); + $fail('double apply must throw'); +} catch (HotSwapException $exception) { + // expected: the sync is single-use +} +echo "idempotency: ok\n"; + +// Exit code 0 also proves the request shuts down cleanly with image-backed +// bodies still published in the executor tables +echo "IMAGE SYNC OK\n"; diff --git a/tests/HotSwap/scripts/image-sync-enum-fixture.php b/tests/HotSwap/scripts/image-sync-enum-fixture.php new file mode 100644 index 00000000..83fca68b --- /dev/null +++ b/tests/HotSwap/scripts/image-sync-enum-fixture.php @@ -0,0 +1,24 @@ +value; + } +} + +function zengine_image_sync_enum_side(): int +{ + return 11; +} diff --git a/tests/HotSwap/scripts/opcache-interface-hook-fixture.php b/tests/HotSwap/scripts/opcache-interface-hook-fixture.php index 21451236..eab0a988 100644 --- a/tests/HotSwap/scripts/opcache-interface-hook-fixture.php +++ b/tests/HotSwap/scripts/opcache-interface-hook-fixture.php @@ -12,8 +12,15 @@ declare(strict_types=1); // Loaded by opcache-interface-hook.php in a child process with opcache enabled. -// Deliberately declares ONLY the interface: the implementor lives in its own -// cached file, so linking it against this interface goes through the opcache -// inheritance cache (the lazy-linking path of issue #238). +// Deliberately declares ONLY interfaces: the implementors live in their own +// cached files, so linking them against these interfaces goes through the +// opcache inheritance cache (the lazy-linking path of issue #238). +// The hooked interface: an interface_gets_implemented handler is installed on it, +// so its implementor receives handlers mid-linking and must be kept process-local +// by declining its inheritance-cache publication (issue #241) interface ZEngineShmHookInterface {} + +// The untouched control: no hook, no handlers - its implementor must still be +// published into the inheritance cache (the interception delegates to opcache) +interface ZEngineShmHookUntouchedInterface {} diff --git a/tests/HotSwap/scripts/opcache-interface-hook-sibling.php b/tests/HotSwap/scripts/opcache-interface-hook-sibling.php new file mode 100644 index 00000000..f5f73348 --- /dev/null +++ b/tests/HotSwap/scripts/opcache-interface-hook-sibling.php @@ -0,0 +1,23 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +// Loaded by opcache-interface-hook.php AFTER the hooked implementor: this class +// links against the UNTOUCHED cached interface, receives no handlers, and must +// therefore still be published into the opcache inheritance cache - proof that +// the zend_inheritance_cache_add interception (issue #241) declines publication +// only for the classes actually recorded in the decline set. + +class ZEngineShmHookSibling implements ZEngineShmHookUntouchedInterface +{ + public int $value = 1; +} diff --git a/tests/HotSwap/scripts/opcache-interface-hook.php b/tests/HotSwap/scripts/opcache-interface-hook.php index 922b2631..c42ca4ed 100644 --- a/tests/HotSwap/scripts/opcache-interface-hook.php +++ b/tests/HotSwap/scripts/opcache-interface-hook.php @@ -13,8 +13,8 @@ use ZEngine\ClassExtension\Hook\InterfaceGetsImplementedHook; use ZEngine\ClassExtension\Hook\WritePropertyHook; +use ZEngine\ClassExtension\ObjectCreateTrait; use ZEngine\Core; -use ZEngine\OpCache\SharedMemoryException; use ZEngine\Reflection\ReflectionClass; require __DIR__ . '/../../../vendor/autoload.php'; @@ -53,10 +53,16 @@ exit(2); } +if (!Core::canDeclineInheritanceCachePublication()) { + // The engine binding of this platform must export zend_inheritance_cache_add: + // without the interception the handlers installed below would be silently lost + $fail('inheritance-cache decline is unavailable (stale engine definitions? run composer gen-headers)'); +} + $observations = [ 'hook-fired' => false, 'lazy-copy' => false, - 'guard-thrown' => false, + 'lazy-address' => 0, 'unexpected' => '', ]; $refInterface->setInterfaceGetsImplementedHandler( @@ -65,13 +71,19 @@ static function (InterfaceGetsImplementedHook $hook) use (&$observations): int { // Nothing may throw OUT of an engine callback (issue #50): every outcome is // recorded and asserted after the linking completed try { - $implementor = $hook->getClass(); - $observations['lazy-copy'] = $implementor->isLazyLinkingCopy(); + $implementor = $hook->getClass(); + $observations['lazy-copy'] = $implementor->isLazyLinkingCopy(); + $observations['lazy-address'] = $implementor->getAddress(); + // The issue #238 reproducer: handlers installed mid-linking must actually + // stick - the decline of the inheritance-cache publication (issue #241) + // keeps this very class entry process-local, so both the create_object + // slot and the address-keyed handlers block stay valid after linking + $implementor->setCreateObjectHandler(Closure::fromCallable([ObjectCreateTrait::class, '__init'])); $implementor->setWritePropertyHandler(static function (WritePropertyHook $propertyHook): mixed { - return $propertyHook->getValue(); + $written = $propertyHook->getValue(); + + return is_int($written) ? $written * 2 : $written; }); - } catch (SharedMemoryException) { - $observations['guard-thrown'] = true; } catch (\Throwable $throwable) { $observations['unexpected'] = $throwable::class . ': ' . $throwable->getMessage(); } @@ -81,6 +93,7 @@ static function (InterfaceGetsImplementedHook $hook) use (&$observations): int { ); require __DIR__ . '/opcache-interface-hook-implementor.php'; +require __DIR__ . '/opcache-interface-hook-sibling.php'; if ($observations['unexpected'] !== '') { $fail("unexpected failure inside the interface hook: {$observations['unexpected']}"); @@ -90,14 +103,47 @@ static function (InterfaceGetsImplementedHook $hook) use (&$observations): int { } if (!$observations['lazy-copy']) { // The hook observed an ordinary entry: opcache's lazy-linking path (inheritance - // cache) did not engage, so the guard has nothing to reject here. The parent + // cache) did not engage, so the decline has nothing to keep alive here. The parent // passes opcache.file_update_protection=0 exactly to prevent the usual cause - // freshly checked-out files being refused by the cache fwrite(STDERR, "the implementor was not observed as a lazy-linking copy\n"); exit(2); } -if (!$observations['guard-thrown']) { - $fail('handler installation on the lazy-linking copy was not rejected (issue #238 would silently lose it)'); + +// The declined class must still be the very entry the hook installed handlers on: +// process-local (not swapped for a published shared-memory copy) and fully linked +$refImplementor = new ReflectionClass(ZEngineShmHookImplementor::class); +if ($refImplementor->getAddress() !== $observations['lazy-address']) { + $fail('the class table no longer publishes the entry the handlers were installed on (publication was not declined)'); +} +if ($refImplementor->isImmutable()) { + $fail('the implementor was published into shared memory although its publication should have been declined'); +} +if ($refImplementor->isLazyLinkingCopy()) { + $fail('the implementor entry never finished linking (ZEND_ACC_LINKED is still clear)'); } -echo "lazy-linking-guard: ok\n"; + +// ...and the handlers installed mid-linking actually fire: the write_property +// handler doubles every integer written to the instance (this exact interaction +// was silently lost before the decline - issue #238). The write runs behind an +// opaque boundary: the installed handler decides the stored value at runtime, +// which no analyser can see +$readBack = static fn(ZEngineShmHookImplementor $subject): int => $subject->value; +$instance = new ZEngineShmHookImplementor(); +$instance->value = 21; +if ($readBack($instance) !== 42) { + $fail('the write_property handler installed during lazy linking did not fire (value: ' . $readBack($instance) . ')'); +} +echo "lazy-linking-handlers: ok\n"; + +// The untouched sibling must NOT pay for the decline: its publication delegates to +// opcache unchanged, so its class-table entry is the immutable shared-memory copy +// the inheritance cache returned +$refSibling = new ReflectionClass(ZEngineShmHookSibling::class); +if ($refSibling->isImmutable()) { + echo "sibling-cache-reuse: ok\n"; +} else { + $fail('the untouched sibling class was not published into the inheritance cache (the interception over-declined)'); +} + echo "INTERFACE HOOK OK\n"; diff --git a/tests/HotSwap/scripts/opcache-matrix.php b/tests/HotSwap/scripts/opcache-matrix.php index 957c211d..80a61dd1 100644 --- a/tests/HotSwap/scripts/opcache-matrix.php +++ b/tests/HotSwap/scripts/opcache-matrix.php @@ -38,6 +38,28 @@ exit(2); } +// --- Same-file redefine targets (issue #242) -------------------------------------- +// Unlike everything in the fixture include, these two are declared in THIS cached +// script: their compiled call sites live in the very shared-memory script that is +// executing them, which is the shape the first-redefine failure of issue #242 came +// from. The seed constant is defined at runtime, so no call to +// zengine_samefile_function() can be pre-evaluated when the script is cached. +define('ZENGINE_SAMEFILE_SEED', 'same-file-original'); + +function zengine_samefile_function(): string +{ + return \ZENGINE_SAMEFILE_SEED; +} + +/** + * Deliberately the optimizer-inlinable shape: a single return of a literal + * (see leg 8 - its same-file call sites are replaced by the constant at cache time) + */ +function zengine_samefile_baked(): string +{ + return 'baked'; +} + /** * Fails the matrix with a diagnostic (exit code 1 = assertion failure) */ @@ -263,6 +285,54 @@ } echo "static-vars-live-table: ok\n"; +// 7. Same-file callers (issue #242): the redefine target is declared in THIS cached +// script. A compiled call site that did not execute before the redefine resolves +// the callee through the repointed function-table bucket on its first run and +// must observe the writable copy - the dispatch closure below was compiled (and +// its op_array persisted) long before the redefine, but runs only after it +$sameFileDispatches = static function (string $expected): bool { + return zengine_samefile_function() === $expected; +}; +$sameFileFunction = new ReflectionFunction('zengine_samefile_function'); +if (!$sameFileFunction->isImmutable()) { + // Without shared memory behind the main script the same-file branch under test + // would silently not be exercised + fwrite(STDERR, "zengine_samefile_function is not an immutable (shared-memory) function\n"); + exit(2); +} +$sameFileFunction->redefine(function (): string { + return 'redefined-same-file'; +}); +if (!$sameFileDispatches('redefined-same-file')) { + $fail('a same-file call site did not observe the redefined body'); +} +if (zengine_samefile_function() !== 'redefined-same-file') { + $fail('the top-level same-file call site did not observe the redefined body'); +} +echo "same-file-redefine: ok\n"; + +// 8. The documented hard limitation behind issue #242: a same-file call to a function +// whose body merely returns a literal is inlined when the script is cached +// (zend_try_inline_call, optimizer pass 4) - the call site below was replaced by +// the constant 'baked' at cache time, so it does not exist at runtime and CANNOT +// observe any redefine, while a dynamic call resolves at runtime and observes it. +// If this leg ever fails on a new PHP build, the engine stopped inlining: revisit +// the copy-out caveat in docs/hot-swap.md together with this assertion. +$bakedDispatches = static function (): string { + return zengine_samefile_baked(); +}; +(new ReflectionFunction('zengine_samefile_baked'))->redefine(function (): string { + return 'redefined-baked'; +}); +$dynamicCallee = 'zengine_samefile_baked'; +$assertSameString($dynamicCallee(), 'redefined-baked', 'a dynamic call does not observe the redefined body'); +$assertSameString( + $bakedDispatches(), + 'baked', + 'the optimizer-inlined call site changed behavior - engine inlining semantics moved', +); +echo "inlined-call-site-limitation: ok\n"; + // Reaching this point with exit code 0 also proves the request shuts down cleanly: // issue #41 crashed in zend_function_dtor()/destroy_zend_class() at request shutdown echo "MATRIX OK\n"; diff --git a/tests/HotSwap/scripts/redefine-plateau.php b/tests/HotSwap/scripts/redefine-plateau.php index 44b1b7d1..c4c9df59 100644 --- a/tests/HotSwap/scripts/redefine-plateau.php +++ b/tests/HotSwap/scripts/redefine-plateau.php @@ -19,31 +19,43 @@ Core::init(); +// Under opcache this whole script is cached and optimized before it runs, and the +// optimizer inlines a same-file call to a function whose body merely returns a +// literal (zend_try_inline_call, optimizer pass 4): such a call site is replaced by +// the constant at cache time, so no redefine() can ever reach it - the original +// failure of issue #242 (see the copy-out caveats in docs/hot-swap.md). The measured +// bodies return a runtime-defined constant instead, which keeps every dispatch below +// a real engine call under any optimization level. +define('PLATEAU_ORIGINAL', 'original'); + function plateau_function(): string { - return 'original'; + return \PLATEAU_ORIGINAL; } class PlateauClass { public function target(): string { - return 'original'; + return \PLATEAU_ORIGINAL; } } $cycles = 1000; $refFunction = new ReflectionFunction('plateau_function'); $refMethod = new ReflectionMethod(PlateauClass::class, 'target'); -$instance = new PlateauClass(); // Dispatch checks take the expected value as data: the bodies are replaced at // runtime, so no statically-known return value applies $functionDispatches = static function (string $expected): bool { return plateau_function() === $expected; }; -$methodDispatches = static function (string $expected) use ($instance): bool { - return $instance->target() === $expected; +// The instance is created inside the dispatch: under opcache the first redefine +// copies PlateauClass out of shared memory, and an instance created before that +// would keep the shared class entry and dispatch the original body forever +// (copy-out redirects name resolution only - docs/hot-swap.md caveats) +$methodDispatches = static function (string $expected): bool { + return (new PlateauClass())->target() === $expected; }; // A fixed rotation of equal-length payloads: every cycle compiles a brand-new diff --git a/tests/OpCache/BoundsValidationTest.php b/tests/OpCache/BoundsValidationTest.php new file mode 100644 index 00000000..f63f4357 --- /dev/null +++ b/tests/OpCache/BoundsValidationTest.php @@ -0,0 +1,191 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use FFI; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Generated\zend_script; + +/** + * Bounds-validation coverage (issue #123): a crafted or truncated binary whose + * stored offsets, counts or element spans escape the declared buffer must be + * refused loudly - never dereferenced. Every stored offset in a .bin is + * attacker-controllable (system_id is a build fingerprint, adler32 is + * forgeable), so an application that feeds an untrusted binary into + * getReflection() must get an exception, not an out-of-bounds engine walk or a + * crash. These tests corrupt a real payload's structural fields and assert the + * loud refusal; they run in the debug container too, where an unguarded + * out-of-bounds read segfaults the loudest. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class BoundsValidationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testTruncatedBufferIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + // The header still claims the full memSize, but the buffer is short: + // scriptOffset now points past the (shrunk) region + $truncated = substr($payload, 0, intdiv(strlen($payload), 2)); + $buffer = $this->bufferOf($truncated, strlen($payload)); + // Shrink the region the relocator believes it has to the real length + $shortMeta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: strlen($truncated), + strSize: 0, + scriptOffset: $meta->scriptOffset(), + timestamp: 0, + checksum: 0, + ); + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/Malformed opcache payload/'); + (new PayloadRelocator($buffer, $shortMeta))->relocate(); + } + + public function testScriptOffsetPastRegionIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $hostileMeta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $meta->memSize(), + strSize: $meta->strSize(), + scriptOffset: $meta->memSize() + 4096, // well past the region + timestamp: 0, + checksum: 0, + ); + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/scriptOffset|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $hostileMeta))->relocate(); + } + + public function testHostileScriptPointerFieldIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Corrupt the script's filename offset to a wild value past the region. + // zend_script is the first member of zend_persistent_script, so the + // script offset is a zend_script* - single-hop to keep the field typed. + $script = Core::pointerAtAddress(zend_script::class, $base + $meta->scriptOffset()); + // The compiled fixture always carries a filename block + \assert($script->filename !== null); + // FFI::addr must stay inline on the pointer-field access to yield the filename SLOT address + // @phpstan-ignore argument.type (FFI::addr of a zend_string* pointer field) + $filenameAt = Core::addressOf(FFI::addr($script->filename)); + Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $filenameAt))[0] = $meta->memSize() + 0x4000; + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/string field filename|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testHostileHashCountIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Blow up the function table's nNumUsed so the bucket walk would spill + $script = Core::pointerAtAddress(zend_script::class, $base + $meta->scriptOffset()); + $functionTableAt = Core::addressOf(Core::addr($script->function_table)); + $functionTable = Core::pointerAtAddress('HashTable *', $functionTableAt); + $functionTable->nNumUsed = 0x7fffffff; + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/count|span|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testHostileInternedStringOffsetIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Tag the script filename as an interned reference far past the (empty) + // string section - a plausible-looking but out-of-range interned offset + $script = Core::pointerAtAddress(zend_script::class, $base + $meta->scriptOffset()); + // The compiled fixture always carries a filename block + \assert($script->filename !== null); + // FFI::addr must stay inline on the pointer-field access to yield the filename SLOT address + // @phpstan-ignore argument.type (FFI::addr of a zend_string* pointer field) + $filenameAt = Core::addressOf(FFI::addr($script->filename)); + Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $filenameAt))[0] = 0x100001; // odd => tagged, offset 0x100000 + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/interned-string offset|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testValidPayloadStillRelocates(): void + { + // The guard must not reject a well-formed image (no false positives) + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate()); + } + + /** + * @return array{string, CacheMetaInfo} + */ + private function compiledPayload(): array + { + $fixture = self::fixturePath(); + $binPath = self::compileFixture($fixture); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + return [$payload, $meta]; + } + + /** + * @return \FFI\CData a writable char[capacity] holding $payload + */ + private function bufferOf(string $payload, ?int $capacity = null): object + { + $capacity = $capacity ?? strlen($payload); + $buffer = Core::new("char[{$capacity}]", false); + if ($payload !== '') { + Core::memcpy($buffer, $payload, strlen($payload)); + } + + return $buffer; + } +} diff --git a/tests/OpCache/ClosureRelocationTest.php b/tests/OpCache/ClosureRelocationTest.php new file mode 100644 index 00000000..f7046fcc --- /dev/null +++ b/tests/OpCache/ClosureRelocationTest.php @@ -0,0 +1,115 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for dynamic function definitions (issue #115): arrow + * functions and anonymous closures - including a closure nested inside another + * closure and one inside a method - must round-trip byte-for-byte and execute + * after a patch-and-save cycle. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class ClosureRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testClosurePayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::closureFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'A closure round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::closureFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + 'cl:42:42:cl-ok', + self::runFromCache($fixture, 'zengine_bin_closures_run', self::$cacheDir), + ); + } + + public function testPatchedClosureFixtureExecutesFromCache(): void + { + $fixture = self::closureFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_closures_run', ':cl-ok', ':cl-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + 'cl:42:42:cl-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_closures_run', self::$cacheDir), + ); + } + + private static function closureFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/closures.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/GraphGrowingSerializerTest.php b/tests/OpCache/GraphGrowingSerializerTest.php new file mode 100644 index 00000000..a298747c --- /dev/null +++ b/tests/OpCache/GraphGrowingSerializerTest.php @@ -0,0 +1,187 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * The from-scratch persist-from-graph serializer (issue #117): every payload + * shape must survive a full re-layout (rebuild an untouched image from its + * graph and execute it), and the acceptance path - graft a brand-new function + * and a new method into a cached script, save(), and have a fresh worker + * execute them straight from the file cache. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class GraphGrowingSerializerTest extends TestCase +{ + use FileCacheFixture; + + /** @var list extra cache directories to clean up */ + private array $extraCacheDirs = []; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + foreach ($this->extraCacheDirs as $directory) { + self::removeDirectory($directory); + } + $this->extraCacheDirs = []; + } + + /** + * @return iterable + */ + public static function rebuildFixtures(): iterable + { + yield 'attributes + statics' => ['answer.php', 'zengine_bin_answer', '41']; + yield 'type lists' => ['type-lists.php', 'zengine_bin_typelist_run', 'ZEngineTypeListImpl:tl-ok']; + yield 'traits' => ['traits.php', 'zengine_bin_trait_run', 'greeter-shared:shouter-shared:greeter:tr-ok']; + yield 'closures' => ['closures.php', 'zengine_bin_closures_run', 'cl:42:42:cl-ok']; + yield 'property hooks' => ['property-hooks.php', 'zengine_bin_hooks_run', '0:40:gauge-40:0:ph-ok']; + yield 'iterators' => ['iterators.php', 'zengine_bin_iterators_run', 'alpha:beta:gamma:delta:it-ok']; + yield 'jumps and consts' => ['addressing-probe.php', 'zengine_bin_probe', 'probe-ok']; + } + + /** + * Rebuilding an UNTOUCHED image from its graph exercises every persist + * walker; the rebuilt binary must be relocator-round-trippable and the + * engine must execute it from the cache. + */ + #[DataProvider('rebuildFixtures')] + public function testRebuiltImageExecutesFromCache(string $fixtureName, string $target, string $expected): void + { + $fixture = self::fixtureByName($fixtureName); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + + $serializer = new ScriptSerializer($file->getReflection()->getRawScript()); + $payload = $serializer->serialize(); + $meta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $serializer->memSize(), + strSize: strlen($payload) - $serializer->memSize(), + scriptOffset: $serializer->scriptOffset(), + timestamp: 0, + checksum: CacheMetaInfo::checksumOf($payload), + ); + file_put_contents($file->binPath(), $meta->toBinary() . $payload); + + $rebuilt = BinaryCacheFile::read($file->binPath(), $fixture); + self::assertTrue($rebuilt->verifyChecksum()); + $rebuilt->getReflection(); // relocate + $rebuilt->save($file->binPath() . '.roundtrip'); + self::assertSame( + (string) file_get_contents($file->binPath()), + (string) file_get_contents($file->binPath() . '.roundtrip'), + 'The rebuilt image must round-trip byte-identically through the relocator', + ); + unlink($file->binPath() . '.roundtrip'); + + self::assertSame($expected, self::runFromCache($fixture, $target, self::$cacheDir)); + } + + /** + * The issue #117 acceptance: a brand-new function AND a new method grafted + * into a cached script execute from the file cache in a fresh worker. + */ + public function testGraftedFunctionAndMethodExecuteFromCache(): void + { + $main = self::fixtureByName('answer.php'); + $donorSrc = self::fixtureByName('graft-donor.php'); + + $file = BinaryCacheFile::read(self::compileFixture($main), $main); + $donorDir = sys_get_temp_dir() . '/zengine-graft-donor-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir($donorDir, 0777, true)); + $this->extraCacheDirs[] = $donorDir; + $donor = BinaryCacheFile::compile($donorSrc, $donorDir); + + $view = $file->getReflection(); + self::assertFalse($view->isGraphGrown()); + $view->addFunctionFrom($donor->getReflection(), 'zengine_bin_added'); + $view->addMethodFrom($donor->getReflection(), 'ZEngineGraftDonor', 'addedReport', 'ZEngineBinSubject'); + self::assertTrue($view->isGraphGrown()); + $file->save(); + + // The written binary is sound: checksum, reflection view, round trip + $reread = BinaryCacheFile::read($file->binPath(), $main); + self::assertTrue($reread->verifyChecksum()); + self::assertArrayHasKey('zengine_bin_added', $reread->getReflection()->getFunctions()); + $reread->save($file->binPath() . '.roundtrip'); + self::assertSame( + (string) file_get_contents($file->binPath()), + (string) file_get_contents($file->binPath() . '.roundtrip'), + 'The grown image must round-trip byte-identically through the relocator', + ); + unlink($file->binPath() . '.roundtrip'); + + // A fresh worker executes the grafts AND the original entries + self::assertSame('added-fn', self::runFromCache($main, 'zengine_bin_added', self::$cacheDir)); + self::assertSame('added-method-ok', self::runFromCache($main, 'ZEngineBinSubject::addedReport', self::$cacheDir)); + self::assertSame('41', self::runFromCache($main, 'zengine_bin_answer', self::$cacheDir)); + } + + public function testGraftRefusesUnknownDonorEntries(): void + { + $main = self::fixtureByName('answer.php'); + $donorSrc = self::fixtureByName('graft-donor.php'); + + $file = BinaryCacheFile::read(self::compileFixture($main), $main); + $donorDir = sys_get_temp_dir() . '/zengine-graft-donor-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir($donorDir, 0777, true)); + $this->extraCacheDirs[] = $donorDir; + $donor = BinaryCacheFile::compile($donorSrc, $donorDir); + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessage("Cannot graft function 'zengine_bin_missing'"); + $file->getReflection()->addFunctionFrom($donor->getReflection(), 'zengine_bin_missing'); + } + + public function testGraftRefusesDuplicateKeys(): void + { + $main = self::fixtureByName('answer.php'); + $donorSrc = self::fixtureByName('graft-donor.php'); + + $file = BinaryCacheFile::read(self::compileFixture($main), $main); + $donorDir = sys_get_temp_dir() . '/zengine-graft-donor-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir($donorDir, 0777, true)); + $this->extraCacheDirs[] = $donorDir; + $donor = BinaryCacheFile::compile($donorSrc, $donorDir); + + $view = $file->getReflection(); + $view->addFunctionFrom($donor->getReflection(), 'zengine_bin_added'); + $this->expectException(OpCacheException::class); + $this->expectExceptionMessage("Cannot graft 'zengine_bin_added'"); + $view->addFunctionFrom($donor->getReflection(), 'zengine_bin_added'); + } + + private static function fixtureByName(string $name): string + { + $path = realpath(__DIR__ . '/fixtures/' . $name); + self::assertIsString($path); + + return $path; + } +} diff --git a/tests/OpCache/IteratorFuncsRelocationTest.php b/tests/OpCache/IteratorFuncsRelocationTest.php new file mode 100644 index 00000000..f85a2c21 --- /dev/null +++ b/tests/OpCache/IteratorFuncsRelocationTest.php @@ -0,0 +1,199 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for iterator_funcs_ptr / arrayaccess_funcs_ptr payloads + * (issue #116). Classes compiled into the file cache are stored unlinked (the + * compiler does not early-bind classes that implement interfaces), so the + * Iterator / IteratorAggregate / ArrayAccess fixture proves such classes + * round-trip byte-for-byte and execute after a patch-and-save cycle; the + * crafted-buffer test drives the pointer walk itself, which only fires for + * payloads whose classes were persisted linked. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class IteratorFuncsRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testIteratorPayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::iteratorFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'An iterator round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::iteratorFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + 'alpha:beta:gamma:delta:it-ok', + self::runFromCache($fixture, 'zengine_bin_iterators_run', self::$cacheDir), + ); + } + + public function testPatchedIteratorFixtureExecutesFromCache(): void + { + $fixture = self::iteratorFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_iterators_run', ':it-ok', ':it-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + 'alpha:beta:gamma:delta:it-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_iterators_run', self::$cacheDir), + ); + } + + /** + * Drives the ported iterator_funcs_ptr / arrayaccess_funcs_ptr walk against a + * crafted image: a zend_class_entry whose struct pointers and zf_* members hold + * serialized offsets (NULL slots included) must relocate to real addresses and + * serialize back to the exact original bytes. + */ + public function testCraftedIteratorFuncsRelocateAndSerializeBack(): void + { + $memSize = 4096; + $buffer = Core::new("char[{$memSize}]", false); + $meta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $memSize, + strSize: 0, + scriptOffset: 0, + timestamp: 0, + checksum: 0, + ); + $relocator = new PayloadRelocator($buffer, $meta); + $base = Core::addressOf(Core::addr($buffer)); + + $ceOffset = 512; + $iteratorOffset = 1024; + $arrayAccessOffset = 1280; + $iteratorSlots = [ + 'zf_new_iterator' => 2048, + 'zf_rewind' => 2112, + 'zf_valid' => 2176, + 'zf_key' => 0, // NULL member must stay NULL through both directions + 'zf_current' => 2240, + 'zf_next' => 2304, + ]; + $arrayAccessSlots = [ + 'zf_offsetget' => 2368, + 'zf_offsetexists' => 2432, + 'zf_offsetset' => 0, + 'zf_offsetunset' => 2496, + ]; + + $ce = Core::pointerAtAddress('zend_class_entry *', $base + $ceOffset); + $ce->iterator_funcs_ptr = Core::pointerAtAddress('zend_class_iterator_funcs *', $iteratorOffset); + $ce->arrayaccess_funcs_ptr = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $arrayAccessOffset); + $iteratorFuncs = Core::pointerAtAddress('zend_class_iterator_funcs *', $base + $iteratorOffset); + foreach ($iteratorSlots as $field => $offset) { + $iteratorFuncs->$field = $offset === 0 ? null : Core::pointerAtAddress('zend_function *', $offset); + } + $arrayAccessFuncs = Core::pointerAtAddress('zend_class_arrayaccess_funcs *', $base + $arrayAccessOffset); + foreach ($arrayAccessSlots as $field => $offset) { + $arrayAccessFuncs->$field = $offset === 0 ? null : Core::pointerAtAddress('zend_function *', $offset); + } + $serializedBytes = \FFI::string($buffer, $memSize); + + $unserialize = new \ReflectionMethod(PayloadRelocator::class, 'unserializeIteratorFuncs'); + $unserialize->invoke($relocator, $ce); + + self::assertSame($base + $iteratorOffset, Core::addressOf($ce->iterator_funcs_ptr)); + self::assertSame($base + $arrayAccessOffset, Core::addressOf($ce->arrayaccess_funcs_ptr)); + foreach ($iteratorSlots as $field => $offset) { + self::assertMemberRelocated($iteratorFuncs->$field, $base, $offset, $field); + } + foreach ($arrayAccessSlots as $field => $offset) { + self::assertMemberRelocated($arrayAccessFuncs->$field, $base, $offset, $field); + } + + $serialize = new \ReflectionMethod(PayloadRelocator::class, 'serializeIteratorFuncs'); + $serialize->invoke($relocator, $ce); + + self::assertSame($serializedBytes, \FFI::string($buffer, $memSize), 'Serializing back must restore the exact original bytes'); + } + + private static function assertMemberRelocated(mixed $member, int $base, int $offset, string $field): void + { + if ($offset === 0) { + self::assertNull($member, "{$field} must stay NULL"); + + return; + } + self::assertInstanceOf(\FFI\CData::class, $member, "{$field} must still be a pointer"); + self::assertSame($base + $offset, Core::addressOf($member), "{$field} must be relocated"); + } + + private static function iteratorFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/iterators.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/OpcodeAddressingModelTest.php b/tests/OpCache/OpcodeAddressingModelTest.php new file mode 100644 index 00000000..d245f913 --- /dev/null +++ b/tests/OpCache/OpcodeAddressingModelTest.php @@ -0,0 +1,147 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Generated\zend_op; +use ZEngine\Generated\zend_persistent_script; +use ZEngine\System\OpCode; +use ZEngine\Type\OpLine; + +/** + * Tripwire for the relocator's opline-opaque contract (issue #119). + * + * PayloadRelocator never rewrites per-opline operands because every 64-bit + * build compiles with relative addressing (ZEND_USE_ABS_CONST_ADDR / + * ZEND_USE_ABS_JMP_ADDR are 1 only when SIZEOF_SIZE_T == 4, zend_compile.h) - + * darwin x64/arm64 included, they are not special. In such payloads IS_CONST + * operands are literal-table indexes and jump operands are opline-relative + * byte offsets, both position-independent. This test proves that on a real + * compiled payload and FAILS - it does not skip - if a supported build ever + * produced absolute (buffer-offset) operands, which would need the + * per-opline walk of zend_file_cache.c that only 32-bit builds compile in. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class OpcodeAddressingModelTest extends TestCase +{ + use FileCacheFixture; + + /** Opcodes whose op1/op2 is a jump target in zend_file_cache.c's ABS-addr switch (CATCH/FE_FETCH/SWITCH use extended_value and are not probed) */ + private const array OP1_JUMPS = [OpCode::JMP]; + private const array OP2_JUMPS = [ + OpCode::JMPZ, OpCode::JMPNZ, OpCode::JMPZ_EX, OpCode::JMPNZ_EX, + OpCode::JMP_SET, OpCode::COALESCE, OpCode::FE_RESET_R, OpCode::FE_RESET_RW, + OpCode::ASSERT_CHECK, OpCode::JMP_NULL, OpCode::BIND_INIT_STATIC_OR_JMP, + OpCode::JMP_FRAMELESS, + ]; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testPayloadOplinesUseRelativeAddressing(): void + { + $binPath = self::compileFixture(self::probeFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + $base = Core::addressOf(Core::addr($buffer)); + $script = Core::pointerAtAddress(zend_persistent_script::class, $base + $meta->scriptOffset()); + $mainOpArray = $script->script->main_op_array; + $lastLiteral = $mainOpArray->last_literal; + $lastOpline = $mainOpArray->last; + $opSize = Core::sizeOfType(zend_op::class); + $opcodes = $mainOpArray->opcodes; + self::assertNotNull($opcodes); + $opcodesAddr = Core::addressOf($opcodes); + $opcodesBytes = $lastOpline * $opSize; + + $constOperands = 0; + $jumpOperands = 0; + for ($i = 0; $i < $lastOpline; $i++) { + $opline = Core::pointerAtAddress(zend_op::class, $opcodesAddr + $i * $opSize); + foreach ([ + 'op1' => [$opline->op1_type, $opline->op1->constant], + 'op2' => [$opline->op2_type, $opline->op2->constant], + ] as $node => [$type, $index]) { + if ($type !== OpLine::IS_CONST) { + continue; + } + $constOperands++; + self::assertLessThan( + $lastLiteral, + $index, + "Opline #{$i} {$node} IS_CONST operand is not a literal-table index - " + . 'this build stores absolute constant addresses (ZEND_USE_ABS_CONST_ADDR), ' + . 'which the relocator does not walk (issue #119)', + ); + } + $rawOffset = null; + $jumpNode = ''; + if (\in_array($opline->opcode, self::OP1_JUMPS, true)) { + [$rawOffset, $jumpNode] = [$opline->op1->jmp_offset, 'op1']; + } elseif (\in_array($opline->opcode, self::OP2_JUMPS, true)) { + [$rawOffset, $jumpNode] = [$opline->op2->jmp_offset, 'op2']; + } + if ($rawOffset !== null) { + $jumpOperands++; + $unpacked = unpack('l', pack('V', $rawOffset)); + self::assertIsArray($unpacked); + $signedOffset = $unpacked[1]; + self::assertIsInt($signedOffset); + $targetPosition = $i * $opSize + $signedOffset; + self::assertTrue( + $targetPosition >= 0 && $targetPosition < $opcodesBytes && $targetPosition % $opSize === 0, + "Opline #{$i} {$jumpNode} jump does not land on an opline of this op_array - " + . 'this build stores absolute jump addresses (ZEND_USE_ABS_JMP_ADDR), ' + . 'which the relocator does not walk (issue #119)', + ); + } + } + + self::assertGreaterThan(0, $constOperands, 'The probe fixture must yield at least one IS_CONST operand'); + self::assertGreaterThan(0, $jumpOperands, 'The probe fixture must yield at least one conditional jump'); + + // And the invariant holds through the writer: untouched round trip stays exact + self::assertSame($payload, $relocator->derelocate()); + } + + private static function probeFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/addressing-probe.php'); + self::assertIsString($path); + + return $path; + } +} diff --git a/tests/OpCache/PropertyHookRelocationTest.php b/tests/OpCache/PropertyHookRelocationTest.php new file mode 100644 index 00000000..8755cee0 --- /dev/null +++ b/tests/OpCache/PropertyHookRelocationTest.php @@ -0,0 +1,115 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for property hooks (issue #113): get/set hook op_arrays + * hanging off zend_property_info - including single-hook properties with a + * NULL slot - must round-trip byte-for-byte and execute after a patch-and-save + * cycle. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class PropertyHookRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testPropertyHookPayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::hookFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'A property-hook round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::hookFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + '0:40:gauge-40:0:ph-ok', + self::runFromCache($fixture, 'zengine_bin_hooks_run', self::$cacheDir), + ); + } + + public function testPatchedPropertyHookFixtureExecutesFromCache(): void + { + $fixture = self::hookFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_hooks_run', ':ph-ok', ':ph-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + '0:40:gauge-40:0:ph-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_hooks_run', self::$cacheDir), + ); + } + + private static function hookFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/property-hooks.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/ReflectionOpcacheFileTest.php b/tests/OpCache/ReflectionOpcacheFileTest.php index 362da386..16a7ad15 100644 --- a/tests/OpCache/ReflectionOpcacheFileTest.php +++ b/tests/OpCache/ReflectionOpcacheFileTest.php @@ -26,8 +26,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/RefreshWorkflowTest.php b/tests/OpCache/RefreshWorkflowTest.php index 1653d175..0b760db9 100644 --- a/tests/OpCache/RefreshWorkflowTest.php +++ b/tests/OpCache/RefreshWorkflowTest.php @@ -32,8 +32,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/SerializerRoundTripTest.php b/tests/OpCache/SerializerRoundTripTest.php index ef48234e..91140a69 100644 --- a/tests/OpCache/SerializerRoundTripTest.php +++ b/tests/OpCache/SerializerRoundTripTest.php @@ -32,8 +32,8 @@ protected function setUp(): void { if (!PayloadRelocator::isSupported()) { self::markTestSkipped( - 'The file-cache relocator supports 64-bit POSIX NTS payloads only' - . ' (ZTS is issue #118, Windows is issue #119)', + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', ); } } diff --git a/tests/OpCache/SharedMemoryRefreshTest.php b/tests/OpCache/SharedMemoryRefreshTest.php new file mode 100644 index 00000000..64e75c40 --- /dev/null +++ b/tests/OpCache/SharedMemoryRefreshTest.php @@ -0,0 +1,261 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Reflection\ReflectionValue; + +/** + * BinaryCacheFile::refresh() semantics under REAL opcache shared memory - the + * SHM-active counterpart of RefreshWorkflowTest (issue #125). Every worker here + * runs with opcache.enable_cli=1 + opcache.file_cache= and WITHOUT + * file_cache_only, so scripts live in shared memory with the file cache as the + * second-level store. + * + * Each CLI process owns a private SHM segment, so the legs are explicit about + * what they prove: + * + * - a warm worker's single include populates BOTH shared memory and the .bin, + * and after a patch + refresh() a FRESH worker (empty SHM, like a pool + * worker after restart) executes the patched body - loaded through + * opcache's own file-cache-into-SHM path, checksums verified, and resident + * in shared memory afterwards (something file_cache_only never exercises); + * - within ONE worker process, save() alone leaves the SHM-resident body in + * service on re-include - the patched binary on disk is NOT re-read until + * invalidated, which is exactly the semantics that motivate refresh(); + * - within ONE worker process, refresh() evicts the SHM-resident copy, the + * patched binary SURVIVES the invalidation (refresh() invalidates before + * it writes, because opcache_invalidate() with opcache.file_cache set + * unlinks the script's .bin - the ordering bug of issue #252), and a + * re-include executes the patched body, loaded from the file cache back + * into shared memory. + * + * The same-process legs run with opcache.revalidate_path=1: after an + * in-process invalidation, opcache's default key lookup finds the invalidated + * hash entry without resolving the script path and never consults the file + * cache again, so same-process pickup of the patched binary requires path + * revalidation (a fresh worker needs no such thing - the fresh-worker leg + * keeps the default lookup on purpose). The negative control runs with the + * same ini, proving its staleness is genuine SHM shielding, not the lookup + * quirk. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class SharedMemoryRefreshTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX NTS payloads only' + . ' (ZTS is issue #118, Windows is issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testFreshWorkerExecutesPatchedBodyFromSharedMemoryAfterRefresh(): void + { + $fixture = self::shmFixturePath(); + $cacheDir = self::freshShmCacheDir(); + + // One include in a warm worker fills shared memory AND the file cache + self::assertSame('value=41 shm=1', self::runShmWorker($fixture, $cacheDir)); + $binPath = BinaryCacheFile::locate($cacheDir, $fixture); + self::assertFileExists($binPath, 'the warm worker must populate the file cache alongside shared memory'); + + // Patch the compiled body in the .bin and refresh it + $file = BinaryCacheFile::read($binPath, $fixture); + self::patchAnswerLiteral($file); + $file->refresh(); + + // A fresh worker (empty SHM) must execute the PATCHED body - and shm=1 + // proves opcache loaded the patched binary INTO shared memory through + // its own consistency-checked file-cache path, not a process-memory + // fallback and not a recompile of the (unchanged) source + self::assertSame('value=42 shm=1', self::runShmWorker($fixture, $cacheDir)); + } + + public function testSharedMemoryResidentScriptIsServedUntilInvalidated(): void + { + $fixture = self::shmFixturePath(); + $cacheDir = self::freshShmCacheDir(); + + // The negative control: in one worker process, patch + save() WITHOUT + // refresh() - the re-include keeps executing the SHM-resident original + $stdout = self::runShmPatchWorker('save', $fixture, $cacheDir); + self::assertStringContainsString('shm-populated: ok', $stdout); + self::assertStringContainsString('patched-literal: ok', $stdout); + self::assertStringContainsString('stale-shm-after-save: ok', $stdout); + self::assertStringContainsString('SHM SAVE OK', $stdout); + + // ...while the patched binary really is on disk: a fresh worker (whose + // empty SHM cannot mask the file cache) executes the patched body, so + // the stale 41 above was shared memory shielding the script - not a + // patch that failed to land + self::assertSame('value=42 shm=1', self::runShmWorker($fixture, $cacheDir)); + } + + public function testSameWorkerReExecutesPatchedBodyAfterRefresh(): void + { + $stdout = self::runShmPatchWorker('refresh', self::shmFixturePath(), self::freshShmCacheDir()); + + self::assertStringContainsString('shm-populated: ok', $stdout); + self::assertStringContainsString('patched-literal: ok', $stdout); + self::assertStringContainsString('refresh-evicts-shm: ok', $stdout); + self::assertStringContainsString('bin-survives-refresh: ok', $stdout); + self::assertStringContainsString('patched-body-on-reinclude: ok', $stdout); + self::assertStringContainsString('SHM REFRESH OK', $stdout); + } + + /** + * Patches the fixture's single long literal 41 -> 42 through the wrappers + */ + private static function patchAnswerLiteral(BinaryCacheFile $file): void + { + $patched = 0; + foreach ($file->getReflection()->getScriptFunction()->getLiterals() as $literal) { + $literal->getNativeValue($value); + if ($literal->getBaseType() === ReflectionValue::IS_LONG && $value === 41) { + $literal->setNativeValue(42); + ++$patched; + } + } + self::assertSame(1, $patched, 'expected to patch exactly one literal in the fixture'); + } + + /** + * Includes the fixture in a worker with shared memory active (file cache as + * the second level, NOT file_cache_only) and returns "value= shm=<0|1>" + */ + private static function runShmWorker(string $fixture, string $cacheDir): string + { + $command = [ + PHP_BINARY, + ...self::sharedMemoryOptions($cacheDir), + __DIR__ . '/scripts/run-shm.php', + $fixture, + ]; + + return self::runWorker($command, 'shared-memory run'); + } + + /** + * Runs the include -> patch -> save()/refresh() sequence inside ONE worker + * process whose private SHM holds the fixture, and returns its stdout. + * + * revalidate_path=1 is what same-process pickup of a refreshed binary + * requires (see the class docblock); the save mode runs with it too, so + * its staleness is proven to be SHM shielding rather than the default + * lookup never consulting the file cache. + */ + private static function runShmPatchWorker(string $mode, string $fixture, string $cacheDir): string + { + $command = [ + PHP_BINARY, + '-d', 'ffi.enable=1', + '-d', 'zend.assertions=1', + '-d', 'assert.exception=1', + '-d', 'display_errors=on', + '-d', 'error_reporting=-1', + '-d', 'memory_limit=512M', + '-d', 'opcache.revalidate_path=1', + ...self::sharedMemoryOptions($cacheDir), + __DIR__ . '/scripts/shm-refresh-worker.php', + $mode, + $fixture, + $cacheDir, + ]; + + return self::runWorker($command, "shared-memory {$mode}"); + } + + /** + * The ini set that makes shared memory + second-level file cache active and + * deterministic: no file_cache_only, no update protection (the fixture may + * be freshly checked out), no timestamp validation (the legs compare cache + * contents, not mtimes) and no JIT (it refuses the file cache) + * + * @return list + */ + private static function sharedMemoryOptions(string $cacheDir): array + { + return [ + '-d', 'opcache.enable=1', + '-d', 'opcache.enable_cli=1', + '-d', 'opcache.file_cache=' . $cacheDir, + '-d', 'opcache.file_cache_consistency_checks=1', + '-d', 'opcache.file_update_protection=0', + '-d', 'opcache.validate_timestamps=0', + '-d', 'opcache.jit=off', + '-d', 'opcache.jit_buffer_size=0', + ]; + } + + /** + * @param list $command + */ + private static function runWorker(array $command, string $label): string + { + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process, "Unable to spawn the {$label} worker"); + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + $report = "STDOUT:\n{$stdout}\nSTDERR:\n{$stderr}"; + if ($exitCode === 2) { + // Never a silent pass: the shared-memory shape was not exercised at all + self::markTestSkipped("Opcache shared memory could not be activated in the {$label} worker\n{$report}"); + } + self::assertSame(0, $exitCode, "The {$label} worker failed ({$exitCode})\n{$report}"); + + return trim($stdout); + } + + private static function shmFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/shm-answer.php'); + self::assertIsString($path); + + return $path; + } + + /** + * A per-test cache directory for the shared-memory workers. Opcache + * disables the file cache when the directory does not exist, so it is + * created here rather than left to the child. + */ + private static function freshShmCacheDir(): string + { + if (!extension_loaded('Zend OPcache')) { + self::markTestSkipped('Zend OPcache extension is not loaded'); + } + self::$cacheDir = sys_get_temp_dir() . '/zengine-opcache-' . bin2hex(random_bytes(6)); + if (!mkdir(self::$cacheDir, 0o755, true)) { + self::fail('Cannot create the file-cache directory ' . self::$cacheDir); + } + + return self::$cacheDir; + } +} diff --git a/tests/OpCache/TraitRelocationTest.php b/tests/OpCache/TraitRelocationTest.php new file mode 100644 index 00000000..6d274907 --- /dev/null +++ b/tests/OpCache/TraitRelocationTest.php @@ -0,0 +1,115 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for trait-using classes (issue #114): trait_names, + * trait_aliases (with and without an explicit trait name) and trait_precedences + * with exclude lists must round-trip byte-for-byte and execute after a + * patch-and-save cycle. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class TraitRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testTraitPayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::traitFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'A trait round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::traitFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + 'greeter-shared:shouter-shared:greeter:tr-ok', + self::runFromCache($fixture, 'zengine_bin_trait_run', self::$cacheDir), + ); + } + + public function testPatchedTraitFixtureExecutesFromCache(): void + { + $fixture = self::traitFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_trait_run', ':tr-ok', ':tr-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + 'greeter-shared:shouter-shared:greeter:tr-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_trait_run', self::$cacheDir), + ); + } + + private static function traitFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/traits.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/TypeListRelocationTest.php b/tests/OpCache/TypeListRelocationTest.php new file mode 100644 index 00000000..ecd5c938 --- /dev/null +++ b/tests/OpCache/TypeListRelocationTest.php @@ -0,0 +1,114 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\Reflection\ReflectionValue; + +/** + * Relocation coverage for zend_type_list payloads (issue #112): union, + * intersection and DNF types in parameters, return types and properties must + * round-trip byte-for-byte and still execute after a patch-and-save cycle. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class TypeListRelocationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testTypeListPayloadRoundTripsByteIdentical(): void + { + $binPath = self::compileFixture(self::typeListFixturePath()); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + $length = strlen($payload); + $buffer = Core::new("char[{$length}]", false); + Core::memcpy($buffer, $payload, $length); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate(), 'A type-list round trip must reproduce the payload byte-for-byte'); + } + + public function testUnmodifiedResaveStillExecutes(): void + { + $fixture = self::typeListFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + $file->getReflection(); // relocate + $file->save(); // re-serialize unchanged + + self::assertTrue(BinaryCacheFile::read($file->binPath())->verifyChecksum()); + self::assertSame( + 'ZEngineTypeListImpl:tl-ok', + self::runFromCache($fixture, 'zengine_bin_typelist_run', self::$cacheDir), + ); + } + + public function testPatchedTypeListFixtureExecutesFromCache(): void + { + $fixture = self::typeListFixturePath(); + $file = BinaryCacheFile::read(self::compileFixture($fixture), $fixture); + self::patchStringLiteral($file, 'zengine_bin_typelist_run', ':tl-ok', ':tl-patched-through-the-relocator'); + $file->save(); + + self::assertSame( + 'ZEngineTypeListImpl:tl-patched-through-the-relocator', + self::runFromCache($fixture, 'zengine_bin_typelist_run', self::$cacheDir), + ); + } + + private static function typeListFixturePath(): string + { + $path = realpath(__DIR__ . '/fixtures/type-lists.php'); + self::assertIsString($path); + + return $path; + } + + private static function patchStringLiteral(BinaryCacheFile $file, string $function, string $from, string $to): void + { + $functions = $file->getReflection()->getFunctions(); + self::assertArrayHasKey($function, $functions, "Function {$function} not found in the cached script"); + foreach ($functions[$function]->getLiterals() as $literal) { + if ($literal->getBaseType() !== ReflectionValue::IS_STRING) { + continue; + } + $literal->getNativeValue($value); + if ($value === $from) { + $literal->setNativeValue($to); + + return; + } + } + self::fail("String literal '{$from}' not found in {$function}"); + } +} diff --git a/tests/OpCache/fixtures/addressing-probe.php b/tests/OpCache/fixtures/addressing-probe.php new file mode 100644 index 00000000..00e420db --- /dev/null +++ b/tests/OpCache/fixtures/addressing-probe.php @@ -0,0 +1,20 @@ + $a + $b; + + return $add(40, 2); + } +} + +function zengine_bin_closures_run(): string +{ + $factor = 3; + $arrow = fn(int $value): int => $value * $factor; + $anon = function (string $word) use ($factor): string { + $inner = fn(): int => $factor + 1; + + return str_repeat($word, $inner() - $factor); + }; + + return $anon('cl') . ':' . $arrow(14) . ':' . ZEngineClosureHost::tally() . ':cl-ok'; +} diff --git a/tests/OpCache/fixtures/graft-donor.php b/tests/OpCache/fixtures/graft-donor.php new file mode 100644 index 00000000..c0182776 --- /dev/null +++ b/tests/OpCache/fixtures/graft-donor.php @@ -0,0 +1,23 @@ + */ +class ZEngineIteratorSteps implements Iterator +{ + /** @var list */ + private array $steps = ['alpha', 'beta']; + private int $index = 0; + + public function current(): string + { + return $this->steps[$this->index]; + } + + public function key(): int + { + return $this->index; + } + + public function next(): void + { + ++$this->index; + } + + public function rewind(): void + { + $this->index = 0; + } + + public function valid(): bool + { + return isset($this->steps[$this->index]); + } +} + +/** @implements IteratorAggregate */ +class ZEngineIteratorBag implements IteratorAggregate +{ + /** @return ArrayIterator */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator(['gamma']); + } +} + +/** @implements ArrayAccess */ +class ZEngineArrayShelf implements ArrayAccess +{ + /** @var array */ + private array $items = []; + + public function offsetExists(mixed $offset): bool + { + return is_string($offset) && isset($this->items[$offset]); + } + + public function offsetGet(mixed $offset): ?string + { + return is_string($offset) ? ($this->items[$offset] ?? null) : null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if (is_string($offset) && is_string($value)) { + $this->items[$offset] = $value; + } + } + + public function offsetUnset(mixed $offset): void + { + if (is_string($offset)) { + unset($this->items[$offset]); + } + } +} + +function zengine_bin_iterators_run(): string +{ + $collected = []; + foreach (new ZEngineIteratorSteps() as $step) { + $collected[] = $step; + } + foreach (new ZEngineIteratorBag() as $extra) { + $collected[] = $extra; + } + + $shelf = new ZEngineArrayShelf(); + $shelf['delta'] = 'delta'; + $collected[] = $shelf['delta'] ?? 'missing'; + unset($shelf['delta']); + + return implode(':', $collected) . ':it-ok'; +} diff --git a/tests/OpCache/fixtures/property-hooks.php b/tests/OpCache/fixtures/property-hooks.php new file mode 100644 index 00000000..9d809ba5 --- /dev/null +++ b/tests/OpCache/fixtures/property-hooks.php @@ -0,0 +1,40 @@ +hooks walk of zend_file_cache_(un)serialize_prop_info: a + * property with both get and set hooks, a get-only virtual property (NULL set + * slot) and a set-only backed property (NULL get slot). + */ +declare(strict_types=1); + +class ZEngineHookedGauge +{ + public int $level = 1 { + get => $this->level * 10; + set(int $value) { + $this->level = max(0, $value); + } + } + + public string $label { + get => 'gauge-' . $this->level; + } + + public int $floor = 0 { + set => max(0, $value); + } +} + +function zengine_bin_hooks_run(): string +{ + $gauge = new ZEngineHookedGauge(); + $gauge->level = -5; + $before = $gauge->level; + $gauge->level = 4; + $gauge->floor = -9; + + return implode(':', [$before, $gauge->level, $gauge->label, $gauge->floor]) . ':ph-ok'; +} diff --git a/tests/OpCache/fixtures/shm-answer.php b/tests/OpCache/fixtures/shm-answer.php new file mode 100644 index 00000000..ca0ea2a8 --- /dev/null +++ b/tests/OpCache/fixtures/shm-answer.php @@ -0,0 +1,13 @@ +shared(), $this->shoutedShared(), $this->whisper()]); + } +} + +function zengine_bin_trait_run(): string +{ + return (new ZEngineTraitUser())->report() . ':tr-ok'; +} diff --git a/tests/OpCache/fixtures/type-lists.php b/tests/OpCache/fixtures/type-lists.php new file mode 100644 index 00000000..c8185f0b --- /dev/null +++ b/tests/OpCache/fixtures/type-lists.php @@ -0,0 +1,47 @@ +union = zengine_bin_intersection($impl); + $impl->dnf = zengine_bin_union_return(false); + + return zengine_bin_union_param($impl->union ?? $impl) . ':tl-ok'; +} diff --git a/tests/OpCache/scripts/run-shm.php b/tests/OpCache/scripts/run-shm.php new file mode 100644 index 00000000..7d6d5008 --- /dev/null +++ b/tests/OpCache/scripts/run-shm.php @@ -0,0 +1,41 @@ + + * file-cache load: `value` is the body the engine actually executed and + * `shm=1` proves the cache binary passed opcache's own loader checks INTO + * shared memory (opcache_is_script_cached() is false for process-memory + * fallbacks). + * + * argv: [1] = fixture script to include (must `return` a value) + * + * Output: "value= shm=<0|1>". Exit 2 when opcache shared memory is not + * active in this process - the parent skips, never a silent pass. + */ +declare(strict_types=1); + +$status = function_exists('opcache_get_status') ? opcache_get_status(false) : false; +if (!is_array($status) || !empty($status['file_cache_only'])) { + fwrite(STDERR, "opcache shared memory is not active in the child process\n"); + exit(2); +} + +$fixture = realpath($argv[1] ?? ''); +if ($fixture === false) { + fwrite(STDERR, "fixture script not found\n"); + exit(1); +} + +$value = include $fixture; +if (!is_int($value)) { + fwrite(STDERR, 'the fixture must return an int, got ' . var_export($value, true) . "\n"); + exit(1); +} +$shm = opcache_is_script_cached($fixture) ? 1 : 0; + +echo "value={$value} shm={$shm}\n"; diff --git a/tests/OpCache/scripts/shm-refresh-worker.php b/tests/OpCache/scripts/shm-refresh-worker.php new file mode 100644 index 00000000..a118c997 --- /dev/null +++ b/tests/OpCache/scripts/shm-refresh-worker.php @@ -0,0 +1,145 @@ + resident, refresh() -> +// evicted): each call site states the expectation that holds at that point +$assertResidency = static function (string $path, bool $resident, string $message) use ($fail): void { + if (opcache_is_script_cached($path) !== $resident) { + $fail($message); + } +}; + +// Binary presence also changes over the script's lifetime (the ordering bug of +// issue #252 was refresh() unlinking its own fresh binary), and PHP's stat +// cache would otherwise keep reporting the pre-refresh() state +$assertBinaryOnDisk = static function (string $path, string $message) use ($fail): void { + clearstatcache(true, $path); + if (!is_file($path)) { + $fail($message); + } +}; + +$mode = $argv[1] ?? ''; +$fixture = realpath($argv[2] ?? ''); +$cacheDir = $argv[3] ?? ''; +if (!in_array($mode, ['save', 'refresh'], true)) { + $fail("unknown mode '{$mode}', expected 'save' or 'refresh'"); +} +if ($fixture === false || $cacheDir === '') { + $fail('usage: shm-refresh-worker.php '); +} + +// 1. Load the fixture: with SHM active + opcache.file_cache set, one include +// populates both the shared-memory hash and the file-cache .bin +$first = include $fixture; +if ($first !== 41) { + $fail('the pristine fixture must return 41, got ' . var_export($first, true)); +} +$assertResidency($fixture, true, 'the fixture is not resident in shared memory after the include'); +$binPath = BinaryCacheFile::locate($cacheDir, $fixture); +$assertBinaryOnDisk($binPath, "the include did not populate the file cache: {$binPath} is missing"); +echo "shm-populated: ok\n"; + +// 2. Patch the compiled body in the .bin through the framework wrappers +Core::init(); +$file = BinaryCacheFile::read($binPath, $fixture); +$main = $file->getReflection()->getScriptFunction(); +$patched = 0; +foreach ($main->getLiterals() as $literal) { + $literal->getNativeValue($value); + if ($literal->getBaseType() === ReflectionValue::IS_LONG && $value === 41) { + $literal->setNativeValue(42); + ++$patched; + } +} +if ($patched !== 1) { + $fail("expected to patch exactly one literal, patched {$patched}"); +} +echo "patched-literal: ok\n"; + +if ($mode === 'save') { + // 3a. save() alone: the patched binary lands on disk, but the script stays + // resident in shared memory - a re-include in THIS process must keep + // executing the original body, proving a SHM-resident script is not + // re-read until it is invalidated (the semantics that motivate refresh()) + $file->save(); + $second = include $fixture; + if ($second !== 41) { + $fail('save() alone must leave the SHM-resident body in service, got ' . var_export($second, true)); + } + $assertResidency($fixture, true, 'save() alone must not evict the shared-memory copy'); + echo "stale-shm-after-save: ok\n"; + echo "SHM SAVE OK\n"; +} else { + // 3b. refresh(): both halves of the contract inside one process. The + // invalidation half evicts the SHM-resident copy; the reload half + // re-includes the script, which must execute the PATCHED body loaded + // from the surviving binary back into shared memory. Same-process + // pickup requires opcache.revalidate_path=1 (with the default key + // lookup the invalidated hash entry short-circuits path resolution + // and the file cache is never consulted again) + if (ini_get('opcache.revalidate_path') !== '1') { + $fail('the refresh mode must run with opcache.revalidate_path=1 - fix the parent test command'); + } + $file->refresh(); + $assertResidency($fixture, false, 'refresh() must evict the shared-memory copy of the fixture'); + echo "refresh-evicts-shm: ok\n"; + $assertBinaryOnDisk($binPath, 'refresh() must leave the patched binary in place, not unlink it (issue #252)'); + echo "bin-survives-refresh: ok\n"; + $second = include $fixture; + if ($second !== 42) { + $fail('the re-include after refresh() must execute the patched body, got ' . var_export($second, true)); + } + $assertResidency($fixture, true, 'the re-include must load the patched binary back into shared memory'); + echo "patched-body-on-reinclude: ok\n"; + echo "SHM REFRESH OK\n"; +} diff --git a/tests/Reflection/ClassSpecializerSlotTest.php b/tests/Reflection/ClassSpecializerSlotTest.php index 807aab9f..3d77de20 100644 --- a/tests/Reflection/ClassSpecializerSlotTest.php +++ b/tests/Reflection/ClassSpecializerSlotTest.php @@ -13,7 +13,6 @@ namespace ZEngine\Reflection; -use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use ZEngine\Stub\TestClass; use ZEngine\Stub\TestSlotSpecializationTemplate; @@ -118,12 +117,6 @@ public function testParameterAndReturnTypesAreAddressedIndependently(): void $instance->setNamed('not an int'); } - // TODO(#131): once literals are copied alongside opcodes the 2GB IS_CONST reach limit - // disappears and this test returns to the opcache-active runner job. Until then a - // shared-memory method body always refuses slot substitution (the template class is - // compiled into SHM when the runner itself has opcache active), so the substitution - // this test asserts cannot happen there by design. - #[Group('opcache-incompatible')] public function testBuiltinTypedParameterIsRewrittenAndEnforced(): void { $newName = 'ZEngine\Stub\Specialized\SlotBuiltinParamCopy'; @@ -190,8 +183,6 @@ public function testConstantFoldedReturnCheckIsRejected(): void ])); } - // TODO(#131): same 2GB IS_CONST reach refusal as testBuiltinTypedParameterIsRewrittenAndEnforced - #[Group('opcache-incompatible')] public function testOpcodeCopyPreservesLiteralsAndJumps(): void { $newName = 'ZEngine\Stub\Specialized\SlotOpcodeRelocationCopy'; diff --git a/tests/Reflection/ReflectionClassTest.php b/tests/Reflection/ReflectionClassTest.php index 3a3d4ab9..78bbbc7c 100644 --- a/tests/Reflection/ReflectionClassTest.php +++ b/tests/Reflection/ReflectionClassTest.php @@ -292,9 +292,9 @@ public function testHandlersInstalledFromInterfaceHookFireWithoutOpcache(): void { if (function_exists('opcache_get_status') && opcache_get_status(false) !== false) { self::markTestSkipped( - 'With opcache active in the runner the implementor links on a lazy-linking copy and ' - . 'handler installation is rejected (issue #238): that shape is covered by ' - . 'OpcacheSupportMatrixTest::testHandlerInstallationDuringLazyLinkingIsRejected', + 'With opcache active in the runner the implementor links on a lazy-linking copy ' + . '(this test asserts the plain non-opcache shape): that shape is covered by ' + . 'OpcacheSupportMatrixTest::testHandlersInstalledDuringLazyLinkingSurviveViaCacheDecline', ); } diff --git a/tools/generator/emit.php b/tools/generator/emit.php index f59ba596..fefe0971 100644 --- a/tools/generator/emit.php +++ b/tools/generator/emit.php @@ -250,6 +250,7 @@ function sliceStructs(string $phpSrc, string $file, array $patterns): string #include "zend_arena.h" #include "zend_exceptions.h" #include "zend_system_id.h" + #include "zend_vm.h" #include "Optimizer/zend_optimizer.h" #include "supplement.h" C; diff --git a/tools/generator/symbols.php b/tools/generator/symbols.php index 6b952424..f318dbd9 100644 --- a/tools/generator/symbols.php +++ b/tools/generator/symbols.php @@ -108,6 +108,10 @@ // Opcode API 'zend_set_user_opcode_handler', 'zend_get_user_opcode_handler', + // Restores an opline's handler pointer from the index form the opcache + // file-cache serializer stores (zend_file_cache.c); the CacheImageSync + // bridge uses it to make relocated image bodies executable in-process + 'zend_deserialize_opcode_handler', // Inheritance / object API 'zend_do_inheritance_ex', 'zend_objects_new', @@ -194,6 +198,13 @@ 'zend_error_cb', 'zend_throw_exception_hook', 'zend_interrupt_function', + // Opcache inheritance-cache hook points (Zend/zend_inheritance.h): opcache + // installs its SHM lookup/publication callbacks here. z-engine intercepts + // the *_add pointer to DECLINE publication of classes that received + // address-keyed handlers while linking on a lazy temporary (issue #241), + // which keeps those classes process-local and mutable (issue #238) + 'zend_inheritance_cache_get', + 'zend_inheritance_cache_add', // 32 hex chars identifying the exact engine build (Zend/zend_system_id.h); // opcache stamps it into every file-cache binary header 'zend_system_id',