Commit 704b00cd769 for php.net

commit 704b00cd76930e639a9e42766a5b4f6466100910
Author: Weilin Du <weilindu@php.net>
Date:   Thu Sep 10 00:02:52 2026 +0800

    Zend: Fix iterator relocation at the current element during rehash (#23607)

    When one iterator points at a hole and another at the next live bucket,
    the relocation loop must include the iterator at that bucket's original
    position. Otherwise it is moved to the following bucket's destination,
    causing a nested by-reference foreach to skip an element.

    Add regression coverage for packed-to-hash conversion and compaction of
    a full mixed table.

diff --git a/NEWS b/NEWS
index 3346d38ea89..365cd24de84 100644
--- a/NEWS
+++ b/NEWS
@@ -17,6 +17,8 @@ PHP                                                                        NEWS
 - Core:
   . Fixed out-of-bounds reads during automatic UTF-16/32 encoding detection.
     (Yudai Takada)
+  . Fixed incorrect internal pointer and foreach iterator positions when
+    compacting arrays with holes. (Weilin Du)
   . Fixed bug GH-15375 (Nested "yield from" skips items after a valid() or
     next() call on the inner generator). (iliaal)
   . Fixed bug GH-23232 (lone namespace separator asks the autoloader for an
diff --git a/Zend/tests/array_dup_internal_pointer_hole.phpt b/Zend/tests/array_dup_internal_pointer_hole.phpt
new file mode 100644
index 00000000000..97fdd40bb1a
--- /dev/null
+++ b/Zend/tests/array_dup_internal_pointer_hole.phpt
@@ -0,0 +1,45 @@
+--TEST--
+Array duplication relocates an internal pointer on a hole, with and without foreach iterators
+--FILE--
+<?php
+function duplicate(array &$values): void {
+    $copy = $values;
+    $values['i'] = 18;
+    echo 'current: ', key($values), '=>', current($values), "\n";
+    next($values);
+    echo 'next: ', key($values), '=>', current($values), "\n";
+    echo 'copy current: ', key($copy), '=>', current($copy), "\n";
+    echo 'copy keys: ', implode(' ', array_keys($copy)), "\n";
+}
+
+foreach ([false, true] as $withIterator) {
+    echo $withIterator ? "With iterator:\n" : "Without iterator:\n";
+    $values = ['a' => 10, 'b' => 11, 'c' => 12, 'd' => 13,
+               'e' => 14, 'f' => 15, 'g' => 16, 'h' => 17];
+    next($values);
+    next($values);
+    // Leave the internal pointer on a hole before several surviving elements.
+    unset($values['a'], $values['b'], $values['c'], $values['d']);
+
+    if ($withIterator) {
+        foreach ($values as &$value) {
+            duplicate($values);
+            break;
+        }
+        unset($value);
+    } else {
+        duplicate($values);
+    }
+}
+?>
+--EXPECT--
+Without iterator:
+current: e=>14
+next: f=>15
+copy current: e=>14
+copy keys: e f g h
+With iterator:
+current: e=>14
+next: f=>15
+copy current: e=>14
+copy keys: e f g h
diff --git a/Zend/tests/array_dup_iterator_past_end.phpt b/Zend/tests/array_dup_iterator_past_end.phpt
new file mode 100644
index 00000000000..80e52e3e5ab
--- /dev/null
+++ b/Zend/tests/array_dup_iterator_past_end.phpt
@@ -0,0 +1,23 @@
+--TEST--
+Array duplication preserves past-the-end iterators when compacting holes
+--FILE--
+<?php
+$values = ['a' => 10, 'b' => 11, 'c' => 12, 'd' => 13];
+unset($values['a'], $values['b']);
+
+foreach ($values as $key => &$value) {
+    echo "$key=>$value\n";
+    if ($key === 'd') {
+        // The iterator is one past the end; COW compacts the preceding holes.
+        $copy = $values;
+        $values['e'] = 14;
+    }
+}
+unset($value);
+echo 'copy: ', implode(' ', array_keys($copy)), "\n";
+?>
+--EXPECT--
+c=>12
+d=>13
+e=>14
+copy: c d
diff --git a/Zend/tests/array_dup_multiple_iterators.phpt b/Zend/tests/array_dup_multiple_iterators.phpt
new file mode 100644
index 00000000000..96f2dbb7f8d
--- /dev/null
+++ b/Zend/tests/array_dup_multiple_iterators.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Array duplication updates iterators at both a hole and the next defined element
+--FILE--
+<?php
+function test(array $values): void {
+    $outerVisits = [];
+    $innerVisits = [];
+    $first = true;
+    foreach ($values as $outerKey => &$outerValue) {
+        $outerVisits[] = "$outerKey=>$outerValue";
+        if ($first) {
+            $first = false;
+            foreach ($values as $innerKey => &$innerValue) {
+                $innerVisits[] = "$innerKey=>$innerValue";
+                if ($innerValue === 11) {
+                    // The outer cursor is at a hole, the inner at the next value.
+                    unset($values['a'], $values['b']);
+                    $copy = $values;
+                    // Trigger copy-on-write duplication, which compacts the holes.
+                    $values['i'] = 18;
+                }
+            }
+            unset($innerValue);
+        }
+    }
+    unset($outerValue);
+    echo 'outer: ', implode(' ', $outerVisits), "\n";
+    echo 'inner: ', implode(' ', $innerVisits), "\n";
+    echo 'copy: ', implode(' ', array_keys($copy)), "\n";
+}
+
+test(['a' => 10, 'b' => 11, 'c' => 12, 'd' => 13,
+      'e' => 14, 'f' => 15, 'g' => 16, 'h' => 17]);
+?>
+--EXPECT--
+outer: a=>10 c=>12 d=>13 e=>14 f=>15 g=>16 h=>17 i=>18
+inner: a=>10 b=>11 c=>12 d=>13 e=>14 f=>15 g=>16 h=>17 i=>18
+copy: c d e f g h
diff --git a/Zend/tests/rehash_multiple_iterators.phpt b/Zend/tests/rehash_multiple_iterators.phpt
new file mode 100644
index 00000000000..cbbfc75d703
--- /dev/null
+++ b/Zend/tests/rehash_multiple_iterators.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Rehashing updates iterators at both a hole and the next defined element
+--FILE--
+<?php
+function test(array $values, $firstKey, $secondKey, $newKey, int $newValue): void {
+    $outerVisits = [];
+    $innerVisits = [];
+    $first = true;
+    foreach ($values as $outerKey => &$outerValue) {
+        $outerVisits[] = "$outerKey=>$outerValue";
+        if ($first) {
+            $first = false;
+            foreach ($values as $innerKey => &$innerValue) {
+                $innerVisits[] = "$innerKey=>$innerValue";
+                if ($innerValue === 11) {
+                    // The outer cursor is at a hole, the inner at the next value.
+                    unset($values[$firstKey], $values[$secondKey]);
+                    $values[$newKey] = $newValue;
+                }
+            }
+            unset($innerValue);
+        }
+    }
+    unset($outerValue);
+    echo 'outer: ', implode(' ', $outerVisits), "\n";
+    echo 'inner: ', implode(' ', $innerVisits), "\n";
+}
+
+// Adding a string key converts packed storage and compacts its holes.
+test([10, 11, 12, 13, 14], 0, 1, 'new', 15);
+
+// Inserting into a full mixed table compacts its holes without growing it.
+test(['a' => 10, 'b' => 11, 'c' => 12, 'd' => 13,
+      'e' => 14, 'f' => 15, 'g' => 16, 'h' => 17], 'a', 'b', 'i', 18);
+?>
+--EXPECT--
+outer: 0=>10 2=>12 3=>13 4=>14 new=>15
+inner: 0=>10 1=>11 2=>12 3=>13 4=>14 new=>15
+outer: a=>10 c=>12 d=>13 e=>14 f=>15 g=>16 h=>17 i=>18
+inner: a=>10 b=>11 c=>12 d=>13 e=>14 f=>15 g=>16 h=>17 i=>18
diff --git a/Zend/zend_hash.c b/Zend/zend_hash.c
index 82d0318428f..b4de0d7b413 100644
--- a/Zend/zend_hash.c
+++ b/Zend/zend_hash.c
@@ -1412,7 +1412,7 @@ ZEND_API void ZEND_FASTCALL zend_hash_rehash(HashTable *ht)
 								do {
 									zend_hash_iterators_update(ht, iter_pos, j);
 									iter_pos = zend_hash_iterators_lower_pos(ht, iter_pos + 1);
-								} while (iter_pos < i);
+								} while (iter_pos <= i);
 							}
 							q++;
 							j++;
@@ -2408,7 +2408,7 @@ static zend_always_inline uint32_t zend_array_dup_elements(HashTable *source, Ha
 			if (EXPECTED(!HT_HAS_ITERATORS(target))) {
 				while (p != end) {
 					if (zend_array_dup_element(source, target, target_idx, p, q, 0, static_keys, with_holes)) {
-						if (source->nInternalPointer == idx) {
+						if (UNEXPECTED(target->nInternalPointer > target_idx && target->nInternalPointer <= idx)) {
 							target->nInternalPointer = target_idx;
 						}
 						target_idx++; q++;
@@ -2421,19 +2421,21 @@ static zend_always_inline uint32_t zend_array_dup_elements(HashTable *source, Ha

 				while (p != end) {
 					if (zend_array_dup_element(source, target, target_idx, p, q, 0, static_keys, with_holes)) {
-						if (source->nInternalPointer == idx) {
+						if (UNEXPECTED(target->nInternalPointer > target_idx && target->nInternalPointer <= idx)) {
 							target->nInternalPointer = target_idx;
 						}
 						if (UNEXPECTED(idx >= iter_pos)) {
 							do {
 								zend_hash_iterators_update(target, iter_pos, target_idx);
 								iter_pos = zend_hash_iterators_lower_pos(target, iter_pos + 1);
-							} while (iter_pos < idx);
+							} while (iter_pos <= idx);
 						}
 						target_idx++; q++;
 					}
 					idx++; p++;
 				}
+				/* Move past-the-end iterators so they can pick up newly appended elements. */
+				_zend_hash_iterators_update(target, source->nNumUsed, target_idx);
 			}
 			return target_idx;
 		}