Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix circumvented added hooks in JIT #17870

Closed
wants to merge 5 commits into from

Conversation

iluuu1994
Copy link
Member

The following code poses a problem in the JIT:

class A {
    public $prop = 1;
}

class B extends A {
    public $prop = 1 {
        get => parent::$prop::get() * 2;
    }
}

function test(A $a) {
    var_dump($a->prop);
}

test(new B);

The JIT would assume A::$prop in test() could be accessed directly through OBJ_PROP_NUM(). However, since child classes can add new hooks to existing properties, this assumption no longer holds.

To avoid introducing more JIT checks, a hooked property that overrides a unhooked property now results in a separate zval slot that is used instead of the parent slot. This causes the JIT to pick the slow path due to an IS_UNDEF value in the parent slot.

zend_class_entry.properties_info_table poses a problem in that zend_get_property_info_for_slot() and friends will be called using the child slot, which does not store its property info, since the parent slot already does. In this case, zend_get_property_info_for_slot() now provides a fallback that will iterate all property infos to find the correct one.

This also uncovered a bug (see Zend/tests/property_hooks/dump.phpt) where the default value of a parent property would accidentally be inherited by the child property.

Fixes GH-17376

@iluuu1994 iluuu1994 requested a review from arnaud-lb February 20, 2025 21:00
@iluuu1994 iluuu1994 changed the base branch from master to PHP-8.4 February 20, 2025 21:05
The following code poses a problem in the JIT:

```php
class A {
    public $prop = 1;
}

class B extends A {
    public $prop = 1 {
        get => parent::$prop::get() * 2;
    }
}

function test(A $a) {
    var_dump($a->prop);
}

test(new B);
```

The JIT would assume A::$prop in test() could be accessed directly
through OBJ_PROP_NUM(). However, since child classes can add new hooks
to existing properties, this assumption no longer holds.

To avoid introducing more JIT checks, a hooked property that overrides a
unhooked property now results in a separate zval slot that is used
instead of the parent slot. This causes the JIT to pick the slow path
due to an IS_UNDEF value in the parent slot.

zend_class_entry.properties_info_table poses a problem in that
zend_get_property_info_for_slot() and friends will be called using the
child slot, which does not store its property info, since the parent
slot already does. In this case, zend_get_property_info_for_slot() now
provides a fallback that will iterate all property infos to find the
correct one.

This also uncovered a bug (see Zend/tests/property_hooks/dump.phpt)
where the default value of a parent property would accidentally be
inherited by the child property.

Fixes phpGH-17376
@iluuu1994
Copy link
Member Author

I have no clue what this Windows failure is about. 😞 I can't reproduce on Linux with asan/msan/valgrind, w/wo opcache, nts/zts, etc. @cmb69 Can you help? I don't have a VM...

@cmb69
Copy link
Member

cmb69 commented Feb 21, 2025

It's a JIT issue. Running ext\random\tests\02_engine\all_serialize_user.phpt under ASan/tracing JIT:

Random\Engine\Test\TestCountingEngine32
=================================================================
==13488==ERROR: AddressSanitizer: access-violation on unknown address 0x000000000014 (pc 0x7ffce98a8fa2 bp 0x0089ff7fc6b0 sp 0x0089ff7fadb0 T0)
==13488==The signal is caused by a READ memory access.
==13488==Hint: address points to the zero page.
    #0 0x7ffce98a8fa1 in verify_readonly_and_avis C:\php-sdk\phpdev\vs17\x64\php-src\ext\opcache\jit\zend_jit_helpers.c:2716
    #1 0x7ffce989b658 in zend_jit_post_inc_typed_prop C:\php-sdk\phpdev\vs17\x64\php-src\ext\opcache\jit\zend_jit_helpers.c:2976
    #2 0x200008000e61  (<unknown module>)
    #3 0x000000007f12  (<unknown module>)
    #4 0x000000000000  (<unknown module>)
    #5 0x000000007f13  (<unknown module>)
    #6 0x1205678a1cd7  (<unknown module>)
    #7 0x1205678a1c7f  (<unknown module>)
    #8 0x11f9678c0877  (<unknown module>)
    #9 0x11f9678c073f  (<unknown module>)
    #10 0x11f9678c0877  (<unknown module>)
    #11 0x002700000027  (<unknown module>)

AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: access-violation C:\php-sdk\phpdev\vs17\x64\php-src\ext\opcache\jit\zend_jit_helpers.c:2716 in verify_readonly_and_avis
==13488==ABORTING

Does that help?

@iluuu1994
Copy link
Member Author

Thank you very much! It might, I'll have a look very soon.

Z_PROP_TABLE_OFFSET already gives us an index into a void* array. Thus,
we only need to multiply by sizeof(void*).
@cmb69
Copy link
Member

cmb69 commented Feb 22, 2025

Looks good. :)

@iluuu1994
Copy link
Member Author

@cmb69 Indeed, it did help 😊 Thanks again!

@iluuu1994 iluuu1994 marked this pull request as ready for review February 22, 2025 00:21
@iluuu1994 iluuu1994 requested a review from dstogov as a code owner February 22, 2025 00:21
Copy link
Member

@arnaud-lb arnaud-lb left a comment

Choose a reason for hiding this comment

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

This looks good to me!

Copy link
Member

@dstogov dstogov left a comment

Choose a reason for hiding this comment

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

I didn't understand the implementation in all details yet, but the idea is clear and seems should work.

In case child hook updates the real property, only the child real property is changed, and the parent property is kept UNDEF forever. Right?

Please, answer my questions. I'll review this once again tomorrow.
@nielsdos please also take a look.

public $prop {
public $prop = 42 {
Copy link
Member

Choose a reason for hiding this comment

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

Why do you need these tests changes?
Does the patch break some previous behavior?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, this patch also fixes a bug. Properties do not inherit default values, but we accidentally did for properties with hooks.

Copy link
Member

Choose a reason for hiding this comment

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

If the fix may be done separately, it's better to do this separately (add bug report and so on).
If the fix relays on new inheritance logic, and can't be simple separated, don't do this.

return table[prop_num];
if (table[prop_num]) {
Copy link
Member

Choose a reason for hiding this comment

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

When table[prop_num] may be NULL?
Only for regular properties overridden with hooked ones?

Copy link
Member Author

Choose a reason for hiding this comment

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

Only for regular properties overridden with hooked ones?

They will only ever be NULL for properties with hooks that override properties without hooks. This could add an EXPECTED() guard, if you like.

Copy link
Member

Choose a reason for hiding this comment

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

OK. I don't care.

@iluuu1994
Copy link
Member Author

In case child hook updates the real property, only the child real property is changed, and the parent property is kept UNDEF forever. Right?

Yes, exactly.

Comment on lines +208 to +212
ZEND_HASH_MAP_FOREACH_PTR(&obj->ce->properties_info, prop_info) {
if (prop_info->offset == offset) {
return prop_info;
}
} ZEND_HASH_FOREACH_END();
Copy link
Member

Choose a reason for hiding this comment

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

Think, if it's possible to improve this linear search.
This is not a big problem or stopper, but it would be good o avoid this.

May be we may store the prop_info somehow differently...
Or may be check only the child properties (skipping ce->parrent->default_properties_count)...

Copy link
Member Author

Choose a reason for hiding this comment

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

May be we may store the prop_info somehow differently...

A straight-forward solution in that regard would be to store a clone of properties_info_table that sets both the parent and child offset to the child property. This was not done in properties_info_table to avoid breaking iteration over the table. That would not be ABI compatible though (modification of zend_class_entry), so should likely be done in master only.

Copy link
Member

Choose a reason for hiding this comment

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

Checking only child properties will already be a welcome improvement

Copy link
Member

@nielsdos nielsdos left a comment

Choose a reason for hiding this comment

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

The concept seems right, but this may break some assumptions elsewhere in code about slots/inheritance.
For example, this code:

<?php

class A {
    public $prop = 1;
}

class B extends A {
    public $prop = 1 { get => parent::$prop::get() * 2; }
}

class C extends B {
    public $prop = 3;
}

function test(A $a) {
    var_dump((array)$a);
}

test(new C);

Now outputs:

array(2) {
  ["prop"]=>
  int(3)
  ["prop"]=>
  int(3)
}

Which is wrong, this worked prior to this patch.

I also tried to play a bit with multiple parent classes, to test how that interacts. But I got some weird behaviour that I cannot understand. This may be another bug unrelated to this but I'll bring it up here to ask and show what I tried to do. This breaks in PHP with or without JIT, it causes an infinite recursion:

<?php

class A {
    public $prop = 1;
}

class B extends A {
    public $prop = 1 { get => parent::$prop::get() * 2; }
}

class C extends B {
    public $prop = 3;
}

function test(A $a) {
    var_dump($a->prop);
}

test(new C);

Something seems off here or I just don't understand how this is supposed to work.

if (use_child_prop && child_info->offset == ZEND_VIRTUAL_PROPERTY_OFFSET) {
child_info->offset = OBJ_PROP_TO_OFFSET(ce->default_properties_count);
ce->default_properties_count++;
ce->default_properties_table = perealloc(ce->default_properties_table, sizeof(zval) * ce->default_properties_count, ce->type == ZEND_INTERNAL_CLASS);
Copy link
Member

Choose a reason for hiding this comment

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

Doing reallocs with linear size increases is not very efficient, but okay this should be rare and happens during inheritance so it shouldn't be a big problem / showstopper.

Copy link
Member Author

Choose a reason for hiding this comment

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

I fully agree ofc. That said, this excerpt comes from zend_declare_typed_property() which will trigger much more commonly than this code path.

Comment on lines +208 to +212
ZEND_HASH_MAP_FOREACH_PTR(&obj->ce->properties_info, prop_info) {
if (prop_info->offset == offset) {
return prop_info;
}
} ZEND_HASH_FOREACH_END();
Copy link
Member

Choose a reason for hiding this comment

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

Checking only child properties will already be a welcome improvement

@iluuu1994
Copy link
Member Author

Nice find, thank you! I'll have a look.

The property info prototype would point to the parent instead of the
first ancestor.
Copy link
Member

@dstogov dstogov left a comment

Choose a reason for hiding this comment

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

I found more related problems...

<?php

class A {
    public $prop = 1;
}

class B extends A {
    public $prop = 2 { get => parent::$prop::get() * 2; }
}

class C extends B {
    public $prop = 3;
}

function test(A $a) {
    var_dump($a);
    var_dump((array)$a);
    var_dump(get_object_vars($a));
    var_dump(unserialize(serialize($a)));
    var_dump(json_decode(json_encode($a)));
}

test(new C);

now output

object(C)#1 (1) {
  ["prop"]=>
  int(3)
}
array(1) {
  ["prop"]=>
  int(3)
}
array(1) {
  ["prop"]=>
  int(6)
}
object(C)#2 (1) {
  ["prop"]=>
  int(3)
}
object(stdClass)#2 (1) {
  ["prop"]=>
  int(6)
}

Interesting this breaks ext/json and not serialize(). This may also affect other third-party extensions.

Also try to pass new B instead of new C. I don't know what should it return, but the output is definitely inconsistent (sometimes 2 sometimes 4). Way be I'm wrong here.

Please also check for consistency of indirect calls to set hook from unserialize() and other similar places

@iluuu1994
Copy link
Member Author

@dstogov This looks correct at first glance. Note that hooks are inherited in child properties, so while Cdoesn't declare them explicitly, it is still there. JSON and get_object_vars call hooks, while the other functions do not. You can find a rationale for each here: https://wiki.php.net/rfc/property-hooks#interaction_with_serialization

@dstogov
Copy link
Member

dstogov commented Feb 26, 2025

@dstogov This looks correct at first glance. Note that hooks are inherited in child properties, so while Cdoesn't declare them explicitly, it is still there. JSON and get_object_vars call hooks, while the other functions do not. You can find a rationale for each here: https://wiki.php.net/rfc/property-hooks#interaction_with_serialization

OK. If this is on purpose it's hard to understand the purpose.
Anyway I don't object any more.

Copy link
Member

@nielsdos nielsdos left a comment

Choose a reason for hiding this comment

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

I wanted to play a bit with this and lazy objects, but was blocked by another issue (unrelated to this). (#17941)
I think this is fine now.

@iluuu1994 iluuu1994 closed this in 376e90f Feb 26, 2025
@iluuu1994
Copy link
Member Author

Thank you all for the reviews and testing!

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.

[BUG] Property hooks + enabled JIT causes unexpected results (segfault for JIT function)
5 participants