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)