Commit 39a230ccda0 for php.net

commit 39a230ccda052255249bf1ecda6d28f8daf6da2b
Author: Jakub Zelenka <bukka@php.net>
Date:   Tue Sep 15 19:28:26 2026 +0200

    main/streams: Pair refused stream error operations with a no-op end

    When the depth limit refuses a begin, the matching end popped an unrelated
    operation and desynchronized the stack.

diff --git a/ext/standard/tests/streams/stream_errors_operation_depth_limit.phpt b/ext/standard/tests/streams/stream_errors_operation_depth_limit.phpt
new file mode 100644
index 00000000000..1eae9c5fd31
--- /dev/null
+++ b/ext/standard/tests/streams/stream_errors_operation_depth_limit.phpt
@@ -0,0 +1,72 @@
+--TEST--
+Stream errors: operations refused by the depth limit keep the stack consistent
+--INI--
+zend.max_allowed_stack_size=-1
+--FILE--
+<?php
+class RecursiveStream
+{
+    public $context;
+    public static int $depth = 0;
+    private int $reads = 0;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        $outermost = self::$depth === 0;
+        if (++self::$depth < 1010) {
+            $f = fopen('recursive-stream://x', 'r', false, $GLOBALS['ctx']);
+            fread($f, 1);
+            fclose($f);
+        }
+        self::$depth--;
+        if (!$outermost) {
+            return 'x';
+        }
+        return ++$this->reads <= 2 ? str_repeat('A', $count + 1) : '';
+    }
+
+    public function stream_eof(): bool
+    {
+        return self::$depth > 0 || $this->reads >= 3;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+stream_wrapper_register('recursive-stream', RecursiveStream::class);
+
+$depthWarnings = 0;
+set_error_handler(static function (int $severity, string $message) use (&$depthWarnings): bool {
+    if (str_contains($message, 'depth exceeded')) {
+        $depthWarnings++;
+        return true;
+    }
+    return false;
+});
+
+$ctx = stream_context_create(['stream' => [
+    'error_mode' => StreamErrorMode::Silent,
+    'error_store' => StreamErrorStore::All,
+    'error_handler' => static function (array $errors): void {
+        echo "handler: " . count($errors) . " error(s)\n";
+    },
+]]);
+
+$stream = fopen('recursive-stream://x', 'r', false, $ctx);
+var_dump(strlen(stream_get_contents($stream)));
+fclose($stream);
+var_dump($depthWarnings > 0, count(stream_last_errors()));
+?>
+--EXPECT--
+handler: 2 error(s)
+int(16384)
+bool(true)
+int(2)
diff --git a/main/streams/php_stream_errors.h b/main/streams/php_stream_errors.h
index 304ca76a64c..c49f518ea4d 100644
--- a/main/streams/php_stream_errors.h
+++ b/main/streams/php_stream_errors.h
@@ -85,6 +85,7 @@ typedef struct {
 	php_stream_error_operation *current_operation;
 	uint32_t operation_depth;
 	uint32_t operation_floor;
+	uint32_t refused_operations;
 	php_stream_stored_error *stored_errors;
 	uint32_t stored_count;
 	php_stream_error_operation operation_pool[PHP_STREAM_ERROR_OPERATION_POOL_SIZE];
diff --git a/main/streams/stream_errors.c b/main/streams/stream_errors.c
index 204fd4be4a5..e16d3425a4d 100644
--- a/main/streams/stream_errors.c
+++ b/main/streams/stream_errors.c
@@ -232,6 +232,7 @@ PHPAPI void php_stream_error_state_cleanup(void)
 	php_stream_error_state *state = &FG(stream_error_state);

 	state->operation_floor = 0;
+	state->refused_operations = 0;
 	while (state->current_operation) {
 		php_stream_error_operation *op = state->current_operation;
 		state->operation_depth--;
@@ -301,6 +302,7 @@ PHPAPI php_stream_error_operation *php_stream_error_operation_begin(void)
 		php_error_docref(NULL, E_WARNING,
 				"Stream error operation depth exceeded (%"PRIu32"), possible infinite recursion",
 				state->operation_depth);
+		state->refused_operations++;
 		return NULL;
 	}

@@ -337,7 +339,10 @@ static void php_stream_error_add(zend_enum_StreamErrorCode code, const char *wra
 		zend_string *message, const char *docref, int severity, bool terminating)
 {
 	php_stream_error_operation *op = FG(stream_error_state).current_operation;
-	ZEND_ASSERT(op != NULL);
+	if (!op) {
+		zend_string_release(message);
+		return;
+	}

 	php_stream_error_entry *entry = emalloc(sizeof(php_stream_error_entry));
 	entry->message = message;
@@ -446,6 +451,11 @@ PHPAPI void php_stream_error_operation_end(const php_stream_context *context)
 	php_stream_error_state *state = &FG(stream_error_state);
 	php_stream_error_operation *op = state->current_operation;

+	if (state->refused_operations > 0) {
+		state->refused_operations--;
+		return;
+	}
+
 	if (!op) {
 		return;
 	}
@@ -552,6 +562,11 @@ PHPAPI void php_stream_error_operation_end_for_stream(const php_stream *stream)
 	php_stream_error_state *state = &FG(stream_error_state);
 	php_stream_error_operation *op = state->current_operation;

+	if (state->refused_operations > 0) {
+		state->refused_operations--;
+		return;
+	}
+
 	if (!op) {
 		return;
 	}
@@ -574,6 +589,11 @@ PHPAPI void php_stream_error_operation_abort(void)
 	php_stream_error_state *state = &FG(stream_error_state);
 	php_stream_error_operation *op = state->current_operation;

+	if (state->refused_operations > 0) {
+		state->refused_operations--;
+		return;
+	}
+
 	if (!op) {
 		return;
 	}