Skip to content

feat(opcache): apply patched cache images to already-loaded code (CacheImageSync) - #266

Merged
lisachenko merged 7 commits into
8.4from
claude/opcache-122-hotswap-wiring
Aug 20, 2026
Merged

feat(opcache): apply patched cache images to already-loaded code (CacheImageSync)#266
lisachenko merged 7 commits into
8.4from
claude/opcache-122-hotswap-wiring

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

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 against Core::$executor tables (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-exported zend_deserialize_opcode_handler. The image itself is never written, so save()/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.num of implicit-$this receivers ignored — the latter is stack garbage from zend_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() as FunctionLikeTrait::copyEntryOutOfSharedMemory(); classes via existing copyOutOfSharedMemory()) → donor materialization → staged swaps (functions then classes, alphabetical) with full PendingBodySwap rollback → commit. Single-use; re-prepare after apply diffs empty (idempotent).

Refusals (throw-or-work, never silent): changed enum/interface/trait methods → HotSwapException::unsupportedKind; internal function/class collisions → new HotSwapException factories; preloaded/hooked/internal-ancestor SHM classes → SharedMemoryException via 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.php export zend_deserialize_opcode_handler; linux nts/zts engine.h regenerated. 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 current 8.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

  1. Exception taxonomy: kind refusals throw HotSwapException (matching existing HotSwap semantics) rather than the issue's loose "SharedMemoryException" wording; SHM copy-out refusals do throw SharedMemoryException. Easy to change if you prefer.
  2. A typed const array holding a constant expression aborts debug builds under opcache.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 version (full first line of php -v): PHP 8.4.19 (cli) (built: Mar 30 2026 19:28:35) (NTS)
  • Thread safety: NTS
  • OS / architecture: Linux x86-64 (Ubuntu)
  • Debug build (--enable-debug)? yes — debug 8.4 container: --group opcache --fail-on-skipped OK (37 tests, 242 assertions), --group internal --process-isolation OK (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-headers is a no-op against the committed headers.

Checklist

  • Targets the minimum affected version branch (8.4) — fixes cascade upward, never downward
  • composer test passes on the matching PHP minor
  • composer phpstan (level max) and composer cs:check are green
  • Tests added or updated; new symbol is an engine global (no new layout_structs entry)
  • tools/generator/symbols.php changed → regenerated for Linux and committed; darwin/windows regenerate via their native workflows on this branch — nothing hand-edited
  • Conventional Commits used for the commit messages

🤖 Generated with Claude Code

https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M


Generated by Claude Code

claude and others added 6 commits August 19, 2026 22:26
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
@lisachenko
lisachenko marked this pull request as ready for review August 19, 2026 23:43
@lisachenko

Copy link
Copy Markdown
Owner Author

Need to rebase + fix

…swap-wiring

# Conflicts:
#	docs/opcache-binary.md

Copy link
Copy Markdown
Owner Author

Rebased and fixed — pushed as merge 1a62dd2 (branch merged up to current 8.4 @ 158dbac, which now includes the full relocator chain #256#260 and #242's #262).

Conflicts: only docs/opcache-binary.md conflicted textually (both sides rewrote the "Scope and limits" list) — kept 8.4's updated "Strict, never silent" bullet (the reloc chain removed the last payload-shape refusals) alongside the CacheImageSync-landed "Deferred" bullet. Everything else the overlap touched auto-merged with both intents intact: the $imageOwner buffer pin in ReflectionOpcacheFile/BinaryCacheFile, copyEntryOutOfSharedMemory() in FunctionLikeTrait, and #242's function-side caveats + the CacheImageSync section in docs/hot-swap.md.

Headers: no re-run needed. All 8 engine.h targets already carry zend_deserialize_opcode_handler — the darwin (#58) and windows (#59) generate workflows had already committed regenerated headers to this branch. symbols.php had zero divergence on 8.4 since the merge-base, and a native linux gen-headers regenerates to a no-op, confirming consistency.

Verified green on the merged tree: default suite (525 tests), opcache-runner mode (opcache-active parent, 525), CacheImageSyncTest under an opcache-active parent (the hermeticity fix survived the merge), --group opcache --fail-on-skipped on both release and the debug-8.4 container (53 tests), --group internal --process-isolation debug-8.4 (165 tests), PHPStan level max, and cs-fixer. CI is re-running against the new head now.


Generated by Claude Code

@lisachenko
lisachenko merged commit 78f2f20 into 8.4 Aug 20, 2026
29 checks passed
@lisachenko
lisachenko deleted the claude/opcache-122-hotswap-wiring branch August 20, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(opcache): wire the binary-patch pipeline to the hot-swap API

2 participants