Skip to content

Fix GH-24270: JIT loses called scope of self::/parent:: in closures - #24287

Closed
RV7PR wants to merge 1 commit into
php:PHP-8.5from
RV7PR:fix/gh-24270-closure-self-scope
Closed

RV7PR wants to merge 1 commit into
php:PHP-8.5from
RV7PR:fix/gh-24270-closure-self-scope

Conversation

@RV7PR

@RV7PR RV7PR commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #24270

The bug

For an INIT_STATIC_METHOD_CALL with self::/parent:: in a non-static closure, zend_jit_init_static_method_call() computes the called scope as a PHI of Z_OBJCE(EX(This)) (bound $this) and Z_CE(EX(This)) (no $this), but the result of ir_PHI_list(values) was discarded instead of being assigned to scope_ref, as the two sibling branches do. scope_ref kept the resolved class, so the callee ran with e.g. Base as called scope instead of the forwarded Child: static::class returns the wrong class, and new static() creates the wrong class or throws "Cannot instantiate abstract class" (the case in the report).

The code is shared by function and tracing JIT, so both are affected. static fn closures take the ZEND_ACC_STATIC branch and are not affected. Introduced by 972d6b6 (Fix JIT for INIT_STATIC_METHOD_CALL in a closure), so PHP-8.5 and up; PHP-8.4 does not have this branch.

The fix

Assign the PHI to scope_ref.

Tests

gh24270_001.phpt (function JIT) and gh24270_002.phpt (tracing JIT, jit_hot_func=1/jit_hot_loop=1) run the same cases from gh24270.inc and print the called scope seen by each closure over 100 calls:

  • self:: from a closure without $this (the report), with a bound $this, and parent:: with a bound $this;
  • self:: and parent:: from closures declared in a trait method, with the trait used by two classes with different parents.

Each case is called through a subclass, so the expected called scope differs from both the closure's scope and the class the method is resolved in; a result taken from either of those fails the test.

The trait cases cover a different code shape: for a trait, zend_get_known_class() returns the trait, the method lookup is skipped and func stays unknown, so function JIT emits the runtime static/non-static split, while tracing JIT takes func from the trace and emits a guard. The trait closure's opcodes are also shared by every using class, so the scope has to come from EX(This) at runtime.

Without the fix every case fails in both tests (function JIT: Base/Mid/A/ParentA etc. on every call; tracing JIT: correct until the trace is compiled, then wrong).

Verified on PHP-8.5 (bbfdd69), debug build, macOS arm64: both new tests fail without the fix and pass with it; the reproducer from the report prints OK with JIT off, function and tracing JIT. ext/opcache/tests (default INI) and Zend/tests + ext/opcache/tests under function and tracing JIT show no failures that are not also present without the change.

Disclaimer

The root-cause analysis, the fix, the tests and this write-up were produced with Claude Opus 5.5 (Anthropic) at my direction; I ran the builds and tests on my machine and verified the results, but I cannot review the C myself. I will relay any review questions and apply requested changes.

zend_jit_init_static_method_call() computes the called scope for a
self:: or parent:: call from a non-static closure as a PHI of
Z_OBJCE(EX(This)) and Z_CE(EX(This)), but discarded the PHI instead of
assigning it to scope_ref. The callee then ran with the resolved class
as called scope, so static::class and new static() returned Base
instead of the forwarded Child (or failed with "Cannot instantiate
abstract class"). Affects function and tracing JIT.

Introduced by 972d6b6 (Fix JIT for INIT_STATIC_METHOD_CALL in a
closure).

@ndossche ndossche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems correct, thanks a lot!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JIT loses called scope for forwarding self:: call from closure, causing new static() to resolve to Base

2 participants