Skip to content

Elide ZEND_VERIFY_RETURN_TYPE when returning $this#22335

Open
azjezz wants to merge 5 commits into
php:masterfrom
carthage-software:this-return-type-check
Open

Elide ZEND_VERIFY_RETURN_TYPE when returning $this#22335
azjezz wants to merge 5 commits into
php:masterfrom
carthage-software:this-return-type-check

Conversation

@azjezz

@azjezz azjezz commented Jun 16, 2026

Copy link
Copy Markdown

Supersedes #21948 at @Girgias request.

closes #21948

Comment thread Zend/Optimizer/dfa_pass.c

if (ce1->num_interfaces) {
uint32_t i;
if (ce1->ce_flags & ZEND_ACC_RESOLVED_INTERFACES) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Status note for reviewers, carrying over @Girgias's "not sure how to test we hit
this case" and @arnaud-lb's MAY_BE_THIS suggestion from #21948.

This generalized safe_instanceof() is currently not reached for return $this.

can_elide_return_type_check() only continues when use_info->ce is set, and inference assigns no ce to $this (there is no MAY_BE_THIS), so for return $this we bail before ever calling can_elide_list_type() / safe_instanceof(). That's why the inherited_interface* tests still show VERIFY_RETURN_TYPE after the optimizer.

I also couldn't reach the unlinked branches via a non-$this value: when inference knows the operand's class it's already ZEND_ACC_LINKED (so the instanceof_function() fast path is taken), and when the class is unlinked/conditional use_info->ce is unset and we bail first. So this RESOLVED_INTERFACES recursion in particular is currently unexercised.

Making the optimizer side actually elide the inherited-interface $this cases needs the MAY_BE_THIS inference @arnaud-lb suggested, so the $this SSA var carries the scope ce.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hm, MAY_BE_THIS is actually already there, the problem seems to be class resolution, not inference.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed, FETCH_THIS already sets use_info->ce = op_array->scope (with is_instanceof), so we do reach can_elide_return_type_check() with the scope class for return $this. Nothing was elided because safe_instanceof() only worked when the target interface/parent resolved via zend_optimizer_get_class_entry(), and for a not-yet-linked class declared in the same file that returns NULL, so the recursion had nothing to walk.

The fix: for an unlinked ce1, match the target against the class's own name / interface_names / parent_name directly (no resolution needed, same idea as the compile-time helper), and walk the parent chain, recursing through any link that does resolve (internal / preloaded / linked classes). That makes the unlinked path reachable, and lets the optimizer prove transitive interface cases the compile-time pass can't.

That also answers the "how to test this": the new tests use a conditionally-declared class (kept unlinked) whose parent/interface is an internal class (so it resolves), class C extends ArrayObject returning Traversable, and class D implements IteratorAggregate returning Traversable. Both show VERIFY_RETURN_TYPE before the optimizer and elided after.

Two latent bugs turned up once the parent walk reached real classes:

  • ce1->parent / ce1->parent_name are a union; for an unlinked class the field holds the name string, so reading ce1->parent passed a zend_string* to safe_instanceof() as a zend_class_entry*, causing a segfault. Resolved via parent_name instead (this path is unlinked-only).
  • ce1->interfaces[i] can be NULL in the RESOLVED_INTERFACES-but-unlinked state (the existing comment warns about it); guarded it.

Soundness is unchanged: a $this that doesn't satisfy the return type is still not elided and TypeErrors at runtime. Full Zend + opcache + reflection pass, including under tracing JIT.

Signed-off-by: azjezz <azjezz@protonmail.com>
@azjezz azjezz marked this pull request as ready for review June 16, 2026 21:03
@azjezz azjezz requested a review from dstogov as a code owner June 16, 2026 21:03
Comment thread Zend/zend_compile.c
Comment on lines +2645 to +2678
static bool zend_is_this_instance_of_name(const zend_string *type_name)
{
if (zend_string_equals_ci(CG(active_class_entry)->name, type_name)) {
return true;
}
if (zend_string_equals_ci(type_name, ZSTR_KNOWN(ZEND_STR_SELF))) {
return true;
}
if (zend_string_equals_ci(type_name, ZSTR_KNOWN(ZEND_STR_PARENT))) {
return true;
}

ZEND_ASSERT((CG(active_class_entry)->ce_flags & ZEND_ACC_LINKED) == 0);
if (CG(active_class_entry)->num_interfaces) {
for (uint32_t i = 0; i < CG(active_class_entry)->num_interfaces; i++) {
if (zend_string_equals_ci(CG(active_class_entry)->interface_names[i].lc_name, type_name)) {
return true;
}
}
}
const zend_string *parent_name = CG(active_class_entry)->parent_name;
if (parent_name && zend_string_equals_ci(parent_name, type_name)) {
return true;
}

return false;
}

static bool zend_is_this_valid_for_return_type(zend_type type)
{
/* Closures can be bound to a class scope, however it might not and this must type error */
if (CG(active_op_array)->fn_flags & ZEND_ACC_CLOSURE) {
return false;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not sure if we should keep the compiler pass.. since it's redundant when the optimiser is enabled..

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.

Go for less complexity, php is overcomplicated as-is. If the optimizer can handle all cases this should be dropped

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.

3 participants