feat(opcache): apply patched cache images to already-loaded code (CacheImageSync) - #266
Conversation
Add Zend/zend_vm.h to the generator's preprocess unit and the function to the manifest, and regenerate the linux targets (native pre-check against the committed manifest was clean; the zts artifacts come from the docker pipeline). The opcache file cache stores every opline handler as an index (zend_serialize_opcode_handler); this ZEND_API counterpart restores the callable handler pointer and is what lets the cache-image bridge make relocated image bodies executable in-process (issue #122). The darwin/windows artifacts cannot be generated on this machine and are refreshed by their native generate workflows, which trigger on pull requests touching tools/generator/**. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M
Wire the file-cache binary-patch pipeline to the runtime hot-swap API (CacheImageSync): a patched ReflectionOpcacheFile image is diffed against the live executor tables and every changed compiled BODY is swapped into the ALREADY-LOADED functions and methods in place, through the existing FunctionBodySwap machinery - no re-include, entry pointers preserved. Until now a patched binary only affected the next include (refresh()). - prepare() is a read-only diff; the equality basis (ImageFunctionDonor) compares body metrics, canonicalized opcodes (IS_CONST operands by literal index across the two storage forms, handlers and the uninitialized op1.num of implicit-$this receivers ignored), CV names, literals and static defaults by value - conservative where equality cannot be proven (array/AST literals re-apply, like ReflectionMethod). - Donor bodies are materialized per entry: opcodes+literals co-allocated into one process block, IS_CONST operands rewritten to the runtime relative form and handlers restored with the engine's own zend_deserialize_opcode_handler - the exact normalization zend_file_cache_unserialize performs. The image buffer is never written, so save()/refresh() stay valid after an apply. - apply() validates refusals first, copies opcache-shared targets out of SHM through the documented paths (redefine()'s function copy-out, extracted as FunctionLikeTrait::copyEntryOutOfSharedMemory(), and ReflectionClass::copyOutOfSharedMemory() for classes), then stages all swaps - functions before classes, alphabetical - and commits only when every swap staged; failures roll all staged bodies back. - Throw-or-work: changed enum/interface/trait methods, internal-name collisions and every SHM copy-out refusal throw; image-only entries are reported as not loaded in the explicit CacheImageSyncReport. - Lifetime: swapped-in bodies are refcount-less (engine never destroys them); the sync pins the materialized blocks and the image view now retains the relocated buffer's owner. - Seam for #121: prepare() is application-agnostic, an SHM publisher consumes the same prepared diff and replaces only the apply() target. The receiver-opcode constant stays untyped on purpose: a typed array constant holding a constant expression trips the debug-build assertion zend_update_class_constant:!EG(exception) under opcache.preload. Fixes #122 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M
…swap-wiring # Conflicts: # docs/opcache-binary.md
The plain and refusal legs of CacheImageSyncTest pair an optimizer-OFF cache image (compiled by BinaryCacheFile::compile with opcache.optimization_level=0) with an unoptimized live side loaded from source, then assert an untouched image diffs as empty. That only holds when both are compiled at the SAME optimization level - the bridge's documented contract. The opcache-runner CI job sets opcache.enable_cli=1 in php.ini, which leaked into these children and ran the optimizer over their live-side require. The live entry then had a genuinely different compiled body (literal folding collapsed the 3-opcode source body to 1), so bodiesEqual() correctly reported a change and the untouched-diff assertion failed. Not a diff-basis gap: an optimizer-transformed body IS different machine code, and the diff is not meant to canonicalize across optimizer passes. Pin opcache.enable_cli=0 in the base child command so the plain leg is deterministic whatever the runner's php.ini says; the shared-memory leg re-enables it through $extraOptions, which come last and win. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M
|
Need to rebase + fix |
…swap-wiring # Conflicts: # docs/opcache-binary.md
|
Rebased and fixed — pushed as merge Conflicts: only Headers: no re-run needed. All 8 Verified green on the merged tree: default suite (525 tests), opcache-runner mode (opcache-active parent, 525), Generated by Claude Code |
What this changes
Fixes #122 — the headline unlock: take a patched file-cache image and apply it to functions/classes already loaded in the live process (until now a patched image only affected the next include). This is what makes AOP / transpiling / source-protection work on live code.
API:
CacheImageSync::prepare(ReflectionOpcacheFile $image)→ read-only diff againstCore::$executortables (getChangedFunctions(),getChangedMethods(),getRefusalReasons(),isEmpty()) →apply(): CacheImageSyncReport— an explicit per-entry record (applied/unchanged/not-loaded), never a silent no-op; refusals throw.Key discovery: a relocated image is walkable but not executable — opcache serializes opline handlers as VM-table indexes and IS_CONST operands as literal indexes.
ImageFunctionDonor::materialize()reproduces the engine's load normalization on a copy: opcodes+literals co-allocated (the ±2 GiB relative-const constraint forces it), IS_CONST operands rewritten to runtime form, handlers restored via the newly-exportedzend_deserialize_opcode_handler. The image itself is never written, sosave()/refresh()stay valid after an apply.Diff basis: body metrics, fn_flags minus storage-only bits, CV names, canonicalized oplines (const operands by literal index across storage forms; handlers and the uninitialized
op1.numof implicit-$thisreceivers ignored — the latter is stack garbage fromzend_delayed_compile_prop, found empirically), literals and static defaults by value. Conservative false positives (array/AST literals re-apply) and false negatives (arg_info/doc-comment-only edits) documented.Ordering/atomicity: refusal validation → SHM copy-outs (function copy-out extracted from
redefine()asFunctionLikeTrait::copyEntryOutOfSharedMemory(); classes via existingcopyOutOfSharedMemory()) → donor materialization → staged swaps (functions then classes, alphabetical) with fullPendingBodySwaprollback → commit. Single-use; re-prepareafter apply diffs empty (idempotent).Refusals (throw-or-work, never silent): changed enum/interface/trait methods →
HotSwapException::unsupportedKind; internal function/class collisions → newHotSwapExceptionfactories; preloaded/hooked/internal-ancestor SHM classes →SharedMemoryExceptionvia the documented copy-out matrix. Unchanged entries of a refused kind pass.Seam for the future:
prepare()is application-agnostic — the same diff could target a different apply site. Direct SHM publication is explicitly NOT such a target (#121 closed as infeasible);refresh()'s file-cache→SHM reload covers that need.Generated defs:
symbols.php/emit.phpexportzend_deserialize_opcode_handler; linux nts/ztsengine.hregenerated. darwin/windows regeneration is dispatched via the native workflows on this branch — do not merge before those commits land and header-drift is green. The branch is merged up to current8.4(reconciled the doc + generated-file overlap with #241/#252/#131; linux headers regenerate to a no-op, confirming consistency).Two notes for the reviewer
HotSwapException(matching existing HotSwap semantics) rather than the issue's loose "SharedMemoryException" wording; SHM copy-out refusals do throwSharedMemoryException. Easy to change if you prefer.const arrayholding a constant expression aborts debug builds underopcache.preload(zend_update_class_constant: !EG(exception)assertion — looks like an upstream PHP debug-build bug); the fixture keeps that constant untyped with the reason in a code comment. Worth a separate minimal report upstream.Environment it was verified on
php -v): PHP 8.4.19 (cli) (built: Mar 30 2026 19:28:35) (NTS)--enable-debug)? yes — debug 8.4 container:--group opcache --fail-on-skippedOK (37 tests, 242 assertions),--group internal --process-isolationOK (165 tests)Also, on the merged tree: default suite OK (509 tests), opcache-runner mode OK (509, group still empty), PHPStan level max clean, cs-fixer clean; linux
gen-headersis a no-op against the committed headers.Checklist
8.4) — fixes cascade upward, never downwardcomposer testpasses on the matching PHP minorcomposer phpstan(level max) andcomposer cs:checkare greenlayout_structsentry)tools/generator/symbols.phpchanged → regenerated for Linux and committed; darwin/windows regenerate via their native workflows on this branch — nothing hand-edited🤖 Generated with Claude Code
https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M
Generated by Claude Code