Skip to content

fix(reflection): free swap-minted resources of SHM-shared bodies; pin the optimizer-inlining semantics of redefine under opcache - #262

Merged
lisachenko merged 1 commit into
8.4from
claude/opcache-242-redefine-under-opcache
Aug 19, 2026
Merged

fix(reflection): free swap-minted resources of SHM-shared bodies; pin the optimizer-inlining semantics of redefine under opcache#262
lisachenko merged 1 commit into
8.4from
claude/opcache-242-redefine-under-opcache

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

What this changes

Fixes #242. The "first redefine() does not take effect under opcache" turned out to be three stacked mechanisms, only one of them a z-engine bug:

  1. Optimizer inlining (the reported symptom): plateau_function() returned a constant, so opcache's pass 4 (zend_try_inline_call, bit 0x8 of the default opcache.optimization_level) replaced every same-file call site with the literal at cache time — the call doesn't exist at runtime, so no repointing can ever affect it. Proved by opcode dumps (INIT_FCALL/DO_UCALL gone), a 4-way probe matrix (only zero-arg/constant-body shapes fail), and single-bit bisection of the optimization level. Copy-out itself was verified correct (bucket repointed; dynamic $name() calls see the new body). Not fixable in the library — now pinned as named semantics: new matrix legs same-file-redefine: ok (a cold same-file call site must observe the copy) and inlined-call-site-limitation: ok (an inlined site stays baked; the leg fails loudly if a future engine changes the pass-4 behavior).
  2. Warm run-time caches (adjacent limitation, now documented): a caller that ran before the first redefine keeps the old zend_function* in its run-time cache slot — mirrors the documented class-side "resolution only is redirected" model; docs/hot-swap.md gains the function-side caveats and corrects one sentence that claimed the opposite. A follow-up issue proposes an opt-in targeted cache patch (maintainer decision).
  3. A real leak, fixed: FunctionBodySwap::destroyPreviousBody() bailed early for refcount-less previous bodies (any donor closure declared in a cached file shares its body with SHM), leaking the swap-minted HEAP_RT_CACHE (16 B/swap) and minted statics duplicates. Verified against php-8.4.19's destroy_op_array: it frees exactly those per-entry resources before its refcount check and never touches a refcount-less body's shared arrays — the fix runs it for both lifetime classes. Plateau evidence: fixedDonor growth 16000 B → 0 over 1000 cycles.

Test strategy: the plateau child pin flips from opcache-off (TODO(#242)) to explicit opcache ON (JIT off), so every default-suite run permanently exercises the originally failing shape, with uninlinable runtime-defined bodies; the matrix legs pin the semantics under the default optimization level.

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 (31 tests, 194 assertions), --group internal --process-isolation OK (165 tests); plateau + matrix children green under engine assertions

Also: reproducer exit 0 with opcache on (3 stable runs, all series flat); default suite OK (503 tests); opcache-runner suite OK (501); PHPStan level max clean; cs-fixer clean.

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; structs dereferenced already in layout_structs
  • tools/generator/symbols.php unchanged — nothing under include/, stubs/ or .phpstorm.meta.php touched
  • Conventional Commits used for the commit messages

🤖 Generated with Claude Code

https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M


Generated by Claude Code

…t SHM-donor swaps

Root cause of the "first redefine() does not take effect" failure (the
plateau child with opcache active): plateau_function()'s body was a single
`return 'original';`, and when opcache caches a script its optimizer INLINES
every same-file call to such a trivial constant-returning function
(zend_try_inline_call, Zend/Optimizer/optimize_func_calls.c, optimizer pass 4
of the default opcache.optimization_level) - the dispatch call sites were
replaced by the literal at cache time, so they did not exist at runtime and
no redefine could ever reach them. The separate-file matrix leg stayed green
because the optimizer cannot resolve a callee outside the script it compiles.
This is a compile-time transformation, not a resolution path the copy-out
could repoint: it is now a documented copy-out caveat in docs/hot-swap.md,
together with its warm-cache twin - a caller whose run-time cache already
resolved the shared-memory entry before the copy-out keeps dispatching it,
because copy-out redirects name resolution only (the rule the class copy-out
has always documented; the method leg additionally tripped over the
"instances created before the copy-out keep the shared class entry" caveat).

The library bug the same run exposed: destroyPreviousBody() returned early
for a previous body without a refcount (a body shared with opcache SHM, e.g.
any donor closure declared in a cached file), leaking the swap-minted
HEAP_RT_CACHE run-time cache and a statics defaults duplicate on every swap -
16 bytes/cycle in the fixed-donor plateau series. destroy_op_array frees
exactly those per-entry resources BEFORE its refcount check and returns
without touching the shared arrays, so the destroy path now runs it for both
lifetime classes instead of bailing out.

Tests make the original failure shape a permanent regression test:

- redefine-plateau.php returns a runtime-defined constant (no call site can
  be inlined at cache time) and instantiates PlateauClass inside the method
  dispatch (created after the first redefine's class copy-out);
- RedefineLeakPlateauTest pins the child to opcache ON (jit off,
  file_update_protection=0), so every suite exercises the same-file
  first-redefine copy-out path; measured overheads stay 0 and the fixed-donor
  series is flat again (was +16000 bytes per 1000 cycles);
- the opcache support matrix gains a same-file-redefine leg (a cold same-file
  call site must observe the writable copy through the repointed bucket) and
  an inlined-call-site-limitation leg pinning the documented pass-4 behavior,
  with the child's optimization_level pinned to the default pipeline.

Fixes #242

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:42
@lisachenko
lisachenko merged commit 158dbac into 8.4 Aug 19, 2026
21 checks passed
@lisachenko
lisachenko deleted the claude/opcache-242-redefine-under-opcache branch August 19, 2026 23:42
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.

bug(opcache): the first redefine() does not take effect when opcache is active in the process (RedefineLeakPlateauTest child)

2 participants