Skip to content

Commit 0e4cd8a

Browse files
Fix GH-12695: skip __get() when __isset() materialised the property
After __isset() returns true under ?? or empty(), re-check the property table before calling __get(). When __isset() materialised the property (a pattern used by lazy proxies), its value is returned directly. isset() itself is unchanged. Closes GH-12695.
1 parent 093a59c commit 0e4cd8a

8 files changed

Lines changed: 271 additions & 1 deletion

NEWS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ PHP NEWS
2121
. Deprecate specifying a nullable return type for __debugInfo(). (timwolla)
2222
. Fixed bug GH-22142 (Assertion failure in zendi_try_get_long() on IS_UNDEF).
2323
(David Carlier)
24+
. Fixed GH-12695 (Wrong magic methods sequence with ?? operator).
25+
(nicolas-grekas)
2426

2527
- BCMath:
2628
. Added NUL-byte validation to BCMath functions. (jorgsowa)

UPGRADING

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ PHP 8.6 UPGRADE NOTES
1919
1. Backward Incompatible Changes
2020
========================================
2121

22+
- Core:
23+
. ??/empty() on a magic property no longer call __get() when __isset()
24+
has materialised the property (a pattern used by lazy proxies). The
25+
freshly-written value is returned directly. isset() is unaffected.
26+
2227
- DOM:
2328
. Properties previously documented as @readonly (e.g. DOMNode::$nodeType,
2429
DOMDocument::$xmlEncoding, DOMEntity::$actualEncoding, ::$encoding,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
--TEST--
2+
GH-12695: __get() is not called under `??` when __isset() materialised the property
3+
--FILE--
4+
<?php
5+
6+
#[AllowDynamicProperties]
7+
class A {
8+
public function __get($n) {
9+
throw new Exception("__get must not be called when __isset materialised the property");
10+
}
11+
public function __isset($n) {
12+
echo " __isset($n)\n";
13+
$this->$n = 123;
14+
return true;
15+
}
16+
}
17+
18+
echo "Dynamic property materialised in __isset, then `??`:\n";
19+
$a = new A;
20+
var_dump($a->foo ?? 'fallback');
21+
22+
echo "\nSame on a declared (unset) property:\n";
23+
class B {
24+
public int $x = 99;
25+
public function __get($n) {
26+
throw new Exception("__get must not be called when __isset materialised the property");
27+
}
28+
public function __isset($n) {
29+
echo " __isset($n)\n";
30+
$this->$n = 7;
31+
return true;
32+
}
33+
}
34+
$b = new B;
35+
unset($b->x);
36+
var_dump($b->x ?? 'fallback');
37+
38+
?>
39+
--EXPECT--
40+
Dynamic property materialised in __isset, then `??`:
41+
__isset(foo)
42+
int(123)
43+
44+
Same on a declared (unset) property:
45+
__isset(x)
46+
int(7)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
--TEST--
2+
GH-12695: empty() also benefits from the post-__isset() materialization re-check
3+
--FILE--
4+
<?php
5+
6+
#[AllowDynamicProperties]
7+
class A {
8+
public function __get($n) {
9+
throw new Exception("__get must not be called when __isset materialised the property");
10+
}
11+
public function __isset($n) {
12+
echo " __isset($n)\n";
13+
$this->$n = $GLOBALS['next_value'];
14+
return true;
15+
}
16+
}
17+
18+
echo "1) empty() when __isset materialised a truthy value: __get is not called, empty=false:\n";
19+
$GLOBALS['next_value'] = 7;
20+
$a = new A;
21+
var_dump(empty($a->foo));
22+
23+
echo "\n2) empty() when __isset materialised a falsy value: __get is not called, empty=true:\n";
24+
$GLOBALS['next_value'] = 0;
25+
$a = new A;
26+
var_dump(empty($a->bar));
27+
28+
echo "\n3) empty() with no materialization: __get is still called (legacy path preserved):\n";
29+
class B {
30+
public function __get($n) {
31+
echo " __get($n)\n";
32+
return 'value';
33+
}
34+
public function __isset($n) {
35+
echo " __isset($n)\n";
36+
return true;
37+
}
38+
}
39+
$b = new B;
40+
var_dump(empty($b->any));
41+
42+
?>
43+
--EXPECT--
44+
1) empty() when __isset materialised a truthy value: __get is not called, empty=false:
45+
__isset(foo)
46+
bool(false)
47+
48+
2) empty() when __isset materialised a falsy value: __get is not called, empty=true:
49+
__isset(bar)
50+
bool(true)
51+
52+
3) empty() with no materialization: __get is still called (legacy path preserved):
53+
__isset(any)
54+
__get(any)
55+
bool(false)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
--TEST--
2+
GH-12695: isset() itself is unchanged (still does not consult __get)
3+
--FILE--
4+
<?php
5+
6+
/* The GH-12695 fix only affects `??` and `empty()` (which currently call
7+
* __get after __isset). The isset() construct itself has never consulted
8+
* __get and continues not to: it returns whatever __isset returned, cast
9+
* to bool. */
10+
11+
class C {
12+
public function __get($n) {
13+
throw new Exception("__get must not be called from a plain isset()");
14+
}
15+
public function __isset($n) {
16+
echo " __isset($n)\n";
17+
return true;
18+
}
19+
}
20+
$c = new C;
21+
var_dump(isset($c->any));
22+
23+
?>
24+
--EXPECT--
25+
__isset(any)
26+
bool(true)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
--TEST--
2+
GH-12695: when __isset() does not materialise the property, __get() is still called
3+
--FILE--
4+
<?php
5+
6+
/* The re-check after __isset() must not affect the legacy path when
7+
* the property is genuinely magic-only: __get() is still called and
8+
* its return value drives `??`'s null check. */
9+
10+
class C {
11+
public function __get($n) {
12+
echo " __get($n)\n";
13+
return 'from-get';
14+
}
15+
public function __isset($n) {
16+
echo " __isset($n)\n";
17+
return true;
18+
}
19+
}
20+
21+
echo "1) `??` when __isset=true and __get returns a value: __get is called:\n";
22+
$c = new C;
23+
var_dump($c->any ?? 'fallback');
24+
25+
echo "\n2) `??` when __isset=true and __get returns null: __get is called and fallback is used:\n";
26+
class D {
27+
public function __get($n) {
28+
echo " __get($n)\n";
29+
return null;
30+
}
31+
public function __isset($n) {
32+
echo " __isset($n)\n";
33+
return true;
34+
}
35+
}
36+
$d = new D;
37+
var_dump($d->any ?? 'fallback');
38+
39+
echo "\n3) `??` when __isset returns false: __get is not called:\n";
40+
class E {
41+
public function __get($n) {
42+
throw new Exception("__get must not be called when __isset returned false");
43+
}
44+
public function __isset($n) {
45+
echo " __isset($n)\n";
46+
return false;
47+
}
48+
}
49+
$e = new E;
50+
var_dump($e->any ?? 'fallback');
51+
52+
?>
53+
--EXPECT--
54+
1) `??` when __isset=true and __get returns a value: __get is called:
55+
__isset(any)
56+
__get(any)
57+
string(8) "from-get"
58+
59+
2) `??` when __isset=true and __get returns null: __get is called and fallback is used:
60+
__isset(any)
61+
__get(any)
62+
string(8) "fallback"
63+
64+
3) `??` when __isset returns false: __get is not called:
65+
__isset(any)
66+
string(8) "fallback"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
--TEST--
2+
GH-12695: object freed by __isset() during materialization
3+
--FILE--
4+
<?php
5+
6+
/* The re-check after __isset() copies the materialised value into the
7+
* caller's return-value buffer before releasing the object. This is
8+
* required because __isset() may drop the last external reference to
9+
* the object (here via $obj = null), so the property table is freed
10+
* by OBJ_RELEASE() right after the re-check. */
11+
12+
class C {
13+
public $prop;
14+
public function __isset($name) {
15+
global $obj;
16+
$obj = null;
17+
$this->prop = 'materialised';
18+
return true;
19+
}
20+
}
21+
22+
$obj = new C();
23+
unset($obj->prop);
24+
var_dump($obj->prop ?? 'fb');
25+
26+
?>
27+
--EXPECT--
28+
string(12) "materialised"

Zend/zend_object_handlers.c

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,35 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int
934934
}
935935

936936
zval_ptr_dtor(&tmp_result);
937+
938+
/* __isset() may have materialised the property by writing into
939+
* the property table. Re-check it before deferring to __get(),
940+
* so the freshly-written value is returned directly without a
941+
* redundant __get() call (GH-12695). The value is copied into
942+
* `rv` because the property table can be freed by the OBJ_RELEASE
943+
* below (e.g. when __isset() drops the last external reference
944+
* to the object). */
945+
if (IS_VALID_PROPERTY_OFFSET(property_offset)) {
946+
retval = OBJ_PROP(zobj, property_offset);
947+
if (Z_TYPE_P(retval) != IS_UNDEF) {
948+
ZVAL_COPY(rv, retval);
949+
retval = rv;
950+
OBJ_RELEASE(zobj);
951+
goto exit;
952+
}
953+
} else if (IS_DYNAMIC_PROPERTY_OFFSET(property_offset)) {
954+
if (zobj->properties != NULL) {
955+
retval = zend_hash_find(zobj->properties, name);
956+
if (retval) {
957+
ZVAL_COPY(rv, retval);
958+
retval = rv;
959+
OBJ_RELEASE(zobj);
960+
goto exit;
961+
}
962+
}
963+
}
964+
retval = &EG(uninitialized_zval);
965+
937966
if (zobj->ce->__get && !((*guard) & IN_GET)) {
938967
goto call_getter;
939968
}
@@ -2487,7 +2516,20 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has
24872516
result = zend_is_true(&rv);
24882517
zval_ptr_dtor(&rv);
24892518
if (has_set_exists == ZEND_PROPERTY_NOT_EMPTY && result) {
2490-
if (EXPECTED(!EG(exception)) && zobj->ce->__get && !((*guard) & IN_GET)) {
2519+
/* GH-12695, see above. */
2520+
zval *prop = NULL;
2521+
if (IS_VALID_PROPERTY_OFFSET(property_offset)) {
2522+
prop = OBJ_PROP(zobj, property_offset);
2523+
if (Z_TYPE_P(prop) == IS_UNDEF) {
2524+
prop = NULL;
2525+
}
2526+
} else if (IS_DYNAMIC_PROPERTY_OFFSET(property_offset)
2527+
&& zobj->properties != NULL) {
2528+
prop = zend_hash_find(zobj->properties, name);
2529+
}
2530+
if (prop) {
2531+
result = i_zend_is_true(prop);
2532+
} else if (EXPECTED(!EG(exception)) && zobj->ce->__get && !((*guard) & IN_GET)) {
24912533
(*guard) |= IN_GET;
24922534
zend_std_call_getter(zobj, name, &rv);
24932535
(*guard) &= ~IN_GET;

0 commit comments

Comments
 (0)