Commit be5e564a015 for php.net
commit be5e564a0156d16774d551e1c2e9a0c498efa16e
Author: Arnaud Le Blanc <365207+arnaud-lb@users.noreply.github.com>
Date: Mon Sep 21 11:10:38 2026 +0200
Fix GH-21999: GC inconsistency after lazy object properties ht is added to roots (#22013)
In the reproducer, a series of events causes a lazy object's properties ht to be a GC root. The lazy object's get_gc() handler however doesn't return the properties ht in this case, but still exposes properties to the GC. This causes properties to be visited twice.
Fixes GH-21999
diff --git a/NEWS b/NEWS
index 982945e5ebf..54b6a5d174e 100644
--- a/NEWS
+++ b/NEWS
@@ -6,6 +6,10 @@ PHP NEWS
. Fix GH-22567 (Windows ZTS CLI SAPI should refresh its TSRMLS cache during
request activation). (matyhtf)
+- Core
+ . Fix GH-21999: GC inconsistency with lazy object, var_dump(), and object
+ comparison. (Arnaud)
+
- DOM:
. Fixed use-after-free when re-constructing a DOMXPath whose php:function
registrations are freed while still reachable from the cycle collector.
diff --git a/Zend/tests/lazy_objects/gh21999-001.phpt b/Zend/tests/lazy_objects/gh21999-001.phpt
new file mode 100644
index 00000000000..dd338b2b9f4
--- /dev/null
+++ b/Zend/tests/lazy_objects/gh21999-001.phpt
@@ -0,0 +1,47 @@
+--TEST--
+GH-21999: GC inconsistency with lazy object, var_dump(), and object comparison
+--CREDITS--
+kid-lxy
+--FILE--
+<?php
+
+class C {
+ public function __construct() {
+ printf("%s\n". __METHOD__);
+ }
+ public $a;
+ public $b;
+}
+
+function test(string $name, object $obj) {
+ $reflector = new ReflectionClass(C::class);
+ $reflector->getProperty('a')->setRawValueWithoutLazyInitialization($obj, new stdClass);
+ var_dump(!$reflector->isUninitializedLazyObject($obj));
+ var_dump($obj); // builds obj->properties
+}
+
+$reflector = new ReflectionClass(C::class);
+$obj = $reflector->newLazyGhost(function ($obj) {
+ $obj->__construct(); // throws: initialization fails
+});
+test('Ghost', $obj);
+try {
+ // zend_std_compare() fetches obj->properties. zend_compare_symbol_tables()
+ // adds obj->properties to GC roots.
+ $obj > $reflector->newLazyProxy(function () {
+ return new C();
+ });
+} catch (\Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), "\n";
+ gc_collect_cycles();
+}
+
+?>
+--EXPECTF--
+bool(false)
+lazy ghost object(C)#%d (1) {
+ ["a"]=>
+ object(stdClass)#%d (0) {
+ }
+}
+ArgumentCountError: 2 arguments are required, 1 given
diff --git a/Zend/tests/lazy_objects/gh21999-002.phpt b/Zend/tests/lazy_objects/gh21999-002.phpt
new file mode 100644
index 00000000000..8807d2546e4
--- /dev/null
+++ b/Zend/tests/lazy_objects/gh21999-002.phpt
@@ -0,0 +1,36 @@
+--TEST--
+GH-21999: GC inconsistency with lazy object, var_dump(), and object comparison
+--FILE--
+<?php
+
+class C {
+ public function __construct() {
+ printf("%s\n". __METHOD__);
+ }
+ public $a;
+ public $b;
+}
+
+function test(string $name, object $obj) {
+ $reflector = new ReflectionClass(C::class);
+ $reflector->getProperty('a')->setRawValueWithoutLazyInitialization($obj, new stdClass);
+ var_dump(!$reflector->isUninitializedLazyObject($obj));
+ var_dump($obj);
+}
+
+$reflector = new ReflectionClass(C::class);
+$obj = $reflector->newLazyGhost(function ($obj) {
+ $obj->__construct();
+});
+$reflector->getProperty('a')->setRawValueWithoutLazyInitialization($obj, $obj);
+
+try {
+ var_dump($obj < $reflector->newLazyGhost(function () {}));
+} catch (\Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), "\n";
+ gc_collect_cycles();
+}
+
+?>
+--EXPECT--
+ArgumentCountError: 2 arguments are required, 1 given
diff --git a/Zend/zend_lazy_objects.c b/Zend/zend_lazy_objects.c
index 59c8ec36a9b..2ca3a5e4656 100644
--- a/Zend/zend_lazy_objects.c
+++ b/Zend/zend_lazy_objects.c
@@ -797,16 +797,28 @@ HashTable *zend_lazy_object_get_gc(zend_object *zobj, zval **table, int *n)
}
zend_get_gc_buffer_add_zval(gc_buffer, &info->u.initializer.zv);
- /* Uninitialized lazy objects can not have dynamic properties, so we can
- * ignore zobj->properties. */
- zval *prop = zobj->properties_table;
- zval *end = prop + zobj->ce->default_properties_count;
- for ( ; prop < end; prop++) {
- zend_get_gc_buffer_add_zval(gc_buffer, prop);
+ /* Lazy objects may have a properties ht in two cases:
+ * - After fetching debug infos
+ * - After lazy init failed in zend_std_get_properties()
+ *
+ * In the latter case zobj->properties is an empty ht. We should ignore it,
+ * otherwise we may mask references to the GC. In the former case we must
+ * return it, otherwise the GC may traverse properties twice in case the ht
+ * it a root by itself. */
+ zend_array *ht;
+ if (zobj->properties && zend_hash_num_elements(zobj->properties) > 0) {
+ ht = zobj->properties;
+ } else {
+ zval *prop = zobj->properties_table;
+ zval *end = prop + zobj->ce->default_properties_count;
+ for ( ; prop < end; prop++) {
+ zend_get_gc_buffer_add_zval(gc_buffer, prop);
+ }
+ ht = NULL;
}
zend_get_gc_buffer_use(gc_buffer, table, n);
- return NULL;
+ return ht;
}
zend_property_info *zend_lazy_object_get_property_info_for_slot(zend_object *obj, zval *slot)