Commit 4f35c154c74 for php.net

commit 4f35c154c74f9dc54f7167e5e927179862a81978
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Sun Jun 14 12:01:11 2026 -0400

    Fix SplObjectStorage getHash() guard leaking across a bailout

    The getHash() recursion guard increments a request-persistent counter
    around the userland getHash() call but decrements it only on the normal
    return path. A bailout inside an overridden getHash() (out-of-memory,
    timeout, or any fatal) skips the decrement, and the counter is never
    reset per request, so on a persistent SAPI every later request on the
    same worker wrongly throws "Modification of SplObjectStorage during
    getHash() is prohibited". Reset the counter in the SPL request init so
    each request starts at zero regardless of how the previous one exited.

    Closes GH-22308

diff --git a/ext/spl/php_spl.c b/ext/spl/php_spl.c
index 2477b371ea4..0610e79196f 100644
--- a/ext/spl/php_spl.c
+++ b/ext/spl/php_spl.c
@@ -563,6 +563,7 @@ PHP_MINIT_FUNCTION(spl)
 PHP_RINIT_FUNCTION(spl) /* {{{ */
 {
 	spl_autoload_extensions = NULL;
+	spl_object_storage_reset_get_hash_depth();
 	return SUCCESS;
 } /* }}} */

diff --git a/ext/spl/spl_observer.c b/ext/spl/spl_observer.c
index f897ab1350c..613bf5384dc 100644
--- a/ext/spl/spl_observer.c
+++ b/ext/spl/spl_observer.c
@@ -47,6 +47,11 @@ static zend_object_handlers spl_handler_MultipleIterator;

 ZEND_TLS uint32_t spl_object_storage_get_hash_depth;

+void spl_object_storage_reset_get_hash_depth(void)
+{
+	spl_object_storage_get_hash_depth = 0;
+}
+
 typedef struct _spl_SplObjectStorage { /* {{{ */
 	HashTable         storage;
 	zend_long         index;
diff --git a/ext/spl/spl_observer.h b/ext/spl/spl_observer.h
index 08d3126d9c8..bbb3ed656cc 100644
--- a/ext/spl/spl_observer.h
+++ b/ext/spl/spl_observer.h
@@ -31,4 +31,6 @@ extern PHPAPI zend_class_entry *spl_ce_MultipleIterator;

 PHP_MINIT_FUNCTION(spl_observer);

+void spl_object_storage_reset_get_hash_depth(void);
+
 #endif /* SPL_OBSERVER_H */
diff --git a/ext/spl/tests/spl_object_storage_gethash_bailout.phpt b/ext/spl/tests/spl_object_storage_gethash_bailout.phpt
new file mode 100644
index 00000000000..a6327bd6209
--- /dev/null
+++ b/ext/spl/tests/spl_object_storage_gethash_bailout.phpt
@@ -0,0 +1,44 @@
+--TEST--
+SplObjectStorage getHash() depth counter is reset after a bailout in a user getHash()
+--SKIPIF--
+<?php
+if (!file_exists(__DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc")) {
+    die("skip sapi/cli/tests/php_cli_server.inc required but not found");
+}
+?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+include __DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc";
+
+$code = <<<'PHP'
+if ($_SERVER["REQUEST_URI"] === "/poison") {
+    class Poison extends SplObjectStorage {
+        public function getHash($o): string {
+            ini_set("memory_limit", "2M");
+            str_repeat("a", 100 * 1024 * 1024);
+            return "x";
+        }
+    }
+    (new Poison())->offsetSet(new stdClass());
+    echo "poison";
+} else {
+    $s = new SplObjectStorage();
+    $s->offsetSet(new stdClass());
+    echo "check-ok count=", count($s);
+}
+PHP;
+
+php_cli_server_start($code, 'router.php');
+
+$base = 'http://' . PHP_CLI_SERVER_ADDRESS;
+// Request 1 bails out (OOM) inside the overridden getHash() mid-offsetSet.
+@file_get_contents($base . '/poison');
+// A later request on the same worker must not be poisoned by a stuck counter.
+echo @file_get_contents($base . '/check'), "\n";
+echo @file_get_contents($base . '/check'), "\n";
+?>
+--EXPECT--
+check-ok count=1
+check-ok count=1