Commit 1074fcb2bf7 for php.net

commit 1074fcb2bf70994b09911ce384f5bc1a8d5fdfaa
Author: Jakub Zelenka <bukka@php.net>
Date:   Tue Sep 15 18:27:41 2026 +0200

    main/streams: Detach errors before reporting in stream error operation end

    The operation can live in the reallocatable overflow array, so it must not
    be touched after user code has run. Handlers also run with the operation
    stack hidden, so errors they raise are reported immediately instead of
    being attached to an enclosing operation.

diff --git a/ext/standard/tests/streams/stream_errors_deep_nesting_in_handler.phpt b/ext/standard/tests/streams/stream_errors_deep_nesting_in_handler.phpt
new file mode 100644
index 00000000000..5d4da690595
--- /dev/null
+++ b/ext/standard/tests/streams/stream_errors_deep_nesting_in_handler.phpt
@@ -0,0 +1,104 @@
+--TEST--
+Stream errors: deeply nested operations started from the error handler
+--FILE--
+<?php
+const OUTER_DEPTH = 9;
+const HANDLER_DEPTH = 8;
+
+class DeepStream
+{
+    public $context;
+    public static int $left = 0;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        if (--self::$left > 0) {
+            $f = fopen('deep-stream://x', 'r', false, $GLOBALS['ctx']);
+            $s = fread($f, 1);
+            fclose($f);
+            return $s;
+        }
+        return str_repeat('A', $count + 1);
+    }
+
+    public function stream_eof(): bool
+    {
+        return true;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+class WideStream
+{
+    public $context;
+    public static int $left = 0;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        if (--self::$left > 0) {
+            $f = fopen('wide-stream://x', 'r');
+            $s = fread($f, 1);
+            fclose($f);
+            return $s;
+        }
+        return 'x';
+    }
+
+    public function stream_eof(): bool
+    {
+        return true;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+stream_wrapper_register('deep-stream', DeepStream::class);
+stream_wrapper_register('wide-stream', WideStream::class);
+
+$busy = false;
+$ctx = stream_context_create(['stream' => [
+    'error_mode' => StreamErrorMode::Silent,
+    'error_store' => StreamErrorStore::All,
+    'error_handler' => static function (array $errors) use (&$busy): void {
+        echo "handler: {$errors[0]->code->name}\n";
+        if ($busy) {
+            return;
+        }
+        $busy = true;
+        WideStream::$left = HANDLER_DEPTH;
+        $f = fopen('wide-stream://x', 'r');
+        fread($f, 1);
+        fclose($f);
+        $busy = false;
+    },
+]]);
+
+DeepStream::$left = OUTER_DEPTH;
+$stream = fopen('deep-stream://x', 'r', false, $ctx);
+var_dump(fread($stream, 1));
+fclose($stream);
+var_dump(count(stream_last_errors()));
+echo "done\n";
+?>
+--EXPECT--
+handler: UserspaceInvalidReturn
+string(1) "A"
+int(1)
+done
diff --git a/ext/standard/tests/streams/stream_errors_handler_outside_operation.phpt b/ext/standard/tests/streams/stream_errors_handler_outside_operation.phpt
new file mode 100644
index 00000000000..f6d6b6cd7ba
--- /dev/null
+++ b/ext/standard/tests/streams/stream_errors_handler_outside_operation.phpt
@@ -0,0 +1,85 @@
+--TEST--
+Stream errors: errors raised inside a handler are reported outside the current operation
+--FILE--
+<?php
+class ErrorStream
+{
+    public $context;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        return str_repeat('A', $count + 1);
+    }
+
+    public function stream_eof(): bool
+    {
+        return true;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+class OuterStream
+{
+    public $context;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        $inner = fopen('error-stream://x', 'r', false, $GLOBALS['ctx']);
+        fread($inner, 1);
+        fclose($inner);
+        return 'x';
+    }
+
+    public function stream_eof(): bool
+    {
+        return true;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+stream_wrapper_register('error-stream', ErrorStream::class);
+stream_wrapper_register('outer-stream', OuterStream::class);
+
+$ctx = stream_context_create(['stream' => [
+    'error_mode' => StreamErrorMode::Silent,
+    'error_handler' => static function (array $errors): void {
+        echo "handler start: {$errors[0]->code->name}\n";
+        include __DIR__ . '/stream_errors_handler_outside_operation_missing.inc';
+        echo "handler end\n";
+    },
+]]);
+
+$stream = fopen('outer-stream://x', 'r');
+var_dump(fread($stream, 1));
+fclose($stream);
+var_dump(count(stream_last_errors()));
+echo "done\n";
+?>
+--EXPECTF--
+handler start: UserspaceInvalidReturn
+
+Warning: include(): Failed to open stream: No such file or directory in %s on line %d
+
+Warning: include(): Failed opening '%sstream_errors_handler_outside_operation_missing.inc' for inclusion (include_path='%s') in %s on line %d
+handler end
+string(1) "x"
+int(1)
+done
diff --git a/ext/standard/tests/streams/stream_errors_handler_recursion.phpt b/ext/standard/tests/streams/stream_errors_handler_recursion.phpt
new file mode 100644
index 00000000000..ca0d19be0af
--- /dev/null
+++ b/ext/standard/tests/streams/stream_errors_handler_recursion.phpt
@@ -0,0 +1,74 @@
+--TEST--
+Stream errors: recursive error handler is stopped by the stack limit
+--SKIPIF--
+<?php
+if (ini_get('zend.max_allowed_stack_size') === false) {
+    die('skip No stack limit support');
+}
+?>
+--INI--
+zend.max_allowed_stack_size=512K
+--FILE--
+<?php
+class ErrorStream
+{
+    public $context;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        return str_repeat('A', $count + 1);
+    }
+
+    public function stream_eof(): bool
+    {
+        return true;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+stream_wrapper_register('error-stream', ErrorStream::class);
+
+$calls = 0;
+$ctx = stream_context_create(['stream' => [
+    'error_mode' => StreamErrorMode::Silent,
+    'error_handler' => static function (array $errors) use (&$calls, &$ctx): void {
+        $calls++;
+        $stream = fopen('error-stream://x', 'r', false, $ctx);
+        fread($stream, 1);
+        fclose($stream);
+    },
+]]);
+
+$stream = fopen('error-stream://x', 'r', false, $ctx);
+try {
+    fread($stream, 1);
+} catch (Error $e) {
+    echo get_class($e), ": ", $e->getMessage(), "\n";
+}
+fclose($stream);
+var_dump($calls > 1);
+
+$ctx = stream_context_create(['stream' => [
+    'error_mode' => StreamErrorMode::Silent,
+    'error_handler' => static function (array $errors): void {
+        echo "handler: " . count($errors) . " error(s)\n";
+    },
+]]);
+$stream = fopen('error-stream://x', 'r', false, $ctx);
+var_dump(fread($stream, 1));
+fclose($stream);
+?>
+--EXPECTF--
+Error: Maximum call stack size of %d bytes (zend.max_allowed_stack_size - zend.reserved_stack_size) reached. Infinite recursion?
+bool(true)
+handler: 1 error(s)
+string(1) "A"
diff --git a/ext/standard/tests/streams/stream_errors_handler_throws.phpt b/ext/standard/tests/streams/stream_errors_handler_throws.phpt
new file mode 100644
index 00000000000..1901ce48527
--- /dev/null
+++ b/ext/standard/tests/streams/stream_errors_handler_throws.phpt
@@ -0,0 +1,62 @@
+--TEST--
+Stream errors: exception thrown from the error handler
+--FILE--
+<?php
+class ErrorStream
+{
+    public $context;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        return str_repeat('A', $count + 1);
+    }
+
+    public function stream_eof(): bool
+    {
+        return true;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+stream_wrapper_register('error-stream', ErrorStream::class);
+
+$ctx = stream_context_create(['stream' => [
+    'error_mode' => StreamErrorMode::Silent,
+    'error_store' => StreamErrorStore::All,
+    'error_handler' => static function (array $errors): void {
+        throw new RuntimeException("handler: {$errors[0]->code->name}");
+    },
+]]);
+
+$stream = fopen('error-stream://x', 'r', false, $ctx);
+try {
+    var_dump(fread($stream, 1));
+} catch (RuntimeException $e) {
+    echo $e->getMessage(), "\n";
+}
+var_dump(count(stream_last_errors()));
+fclose($stream);
+
+$stream = fopen('error-stream://x', 'r', false, $ctx);
+try {
+    var_dump(fread($stream, 1));
+} catch (RuntimeException $e) {
+    echo $e->getMessage(), "\n";
+}
+var_dump(count(stream_last_errors()));
+fclose($stream);
+?>
+--EXPECT--
+handler: UserspaceInvalidReturn
+int(1)
+handler: UserspaceInvalidReturn
+int(1)
diff --git a/ext/standard/tests/streams/stream_errors_legacy_handler_raises_error.phpt b/ext/standard/tests/streams/stream_errors_legacy_handler_raises_error.phpt
new file mode 100644
index 00000000000..ebaf4407997
--- /dev/null
+++ b/ext/standard/tests/streams/stream_errors_legacy_handler_raises_error.phpt
@@ -0,0 +1,54 @@
+--TEST--
+Stream errors: stream error raised from a legacy error handler while reporting
+--FILE--
+<?php
+class ErrorStream
+{
+    public $context;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        return str_repeat('A', $count + 1);
+    }
+
+    public function stream_eof(): bool
+    {
+        return true;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+stream_wrapper_register('error-stream', ErrorStream::class);
+
+$calls = 0;
+set_error_handler(static function (int $severity, string $message) use (&$calls): bool {
+    $calls++;
+    echo "handler: $message\n";
+    include __DIR__ . '/stream_errors_legacy_handler_raises_error_missing.inc';
+    echo "handler end\n";
+    return true;
+});
+
+$stream = fopen('error-stream://x', 'r');
+var_dump(fread($stream, 1));
+fclose($stream);
+var_dump($calls);
+?>
+--EXPECTF--
+handler: fread(): ErrorStream::stream_read - read 1 bytes more data than requested (8193 read, 8192 max) - excess data will be lost
+
+Warning: include(): Failed to open stream: No such file or directory in %s on line %d
+
+Warning: include(): Failed opening '%sstream_errors_legacy_handler_raises_error_missing.inc' for inclusion (include_path='%s') in %s on line %d
+handler end
+string(1) "A"
+int(1)
diff --git a/main/streams/php_stream_errors.h b/main/streams/php_stream_errors.h
index da7a4cc84e9..304ca76a64c 100644
--- a/main/streams/php_stream_errors.h
+++ b/main/streams/php_stream_errors.h
@@ -84,6 +84,7 @@ typedef struct _php_stream_stored_error {
 typedef struct {
 	php_stream_error_operation *current_operation;
 	uint32_t operation_depth;
+	uint32_t operation_floor;
 	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 94a0b5692ee..204fd4be4a5 100644
--- a/main/streams/stream_errors.c
+++ b/main/streams/stream_errors.c
@@ -178,9 +178,8 @@ static php_stream_error_store php_stream_get_error_store_mode(

 /* Helper functions */

-static bool php_stream_has_terminating_error(const php_stream_error_operation *op)
+static bool php_stream_has_terminating_error(const php_stream_error_entry *entry)
 {
-	const php_stream_error_entry *entry = op->first_error;
 	while (entry) {
 		if (entry->terminating) {
 			return true;
@@ -207,7 +206,7 @@ static inline php_stream_error_operation *php_stream_get_parent_operation(void)
 {
 	const php_stream_error_state *state = &FG(stream_error_state);

-	if (state->operation_depth < 1) {
+	if (state->operation_depth <= state->operation_floor) {
 		return NULL;
 	}

@@ -232,6 +231,7 @@ PHPAPI void php_stream_error_state_cleanup(void)
 {
 	php_stream_error_state *state = &FG(stream_error_state);

+	state->operation_floor = 0;
 	while (state->current_operation) {
 		php_stream_error_operation *op = state->current_operation;
 		state->operation_depth--;
@@ -375,9 +375,9 @@ static void php_stream_call_error_handler(const zval *handler, zval *errors_arra
 	zend_call_known_fcc(&fcc, NULL, 1, errors_array, NULL);
 }

-static void php_stream_throw_exception_with_errors(const php_stream_error_operation *op)
+static void php_stream_throw_exception_with_errors(const php_stream_error_entry *first_error)
 {
-	if (!op->first_error) {
+	if (!first_error) {
 		return;
 	}

@@ -386,27 +386,27 @@ static void php_stream_throw_exception_with_errors(const php_stream_error_operat

 	/* Set message from first error */
 	zend_update_property_str(php_ce_stream_exception, Z_OBJ(ex), ZEND_STRL("message"),
-			op->first_error->message);
+			first_error->message);

 	/* Set code from first error */
 	zend_update_property_long(php_ce_stream_exception, Z_OBJ(ex), ZEND_STRL("code"),
-			(zend_long) op->first_error->code);
+			(zend_long) first_error->code);

 	/* Build errors array and set it */
 	zval errors_array;
-	php_stream_error_create_array(&errors_array, op->first_error);
+	php_stream_error_create_array(&errors_array, first_error);
 	zend_update_property(php_ce_stream_exception, Z_OBJ(ex), ZEND_STRL("errors"), &errors_array);
 	zval_ptr_dtor(&errors_array);

 	zend_throw_exception_object(&ex);
 }

-static void php_stream_report_errors(const php_stream_context *context, const php_stream_error_operation *op,
+static void php_stream_report_errors(const php_stream_context *context, const php_stream_error_entry *first_error,
 		php_stream_error_mode error_mode, bool is_terminating)
 {
 	switch (error_mode) {
 		case PHP_STREAM_ERROR_MODE_ERROR: {
-			const php_stream_error_entry *entry = op->first_error;
+			const php_stream_error_entry *entry = first_error;
 			while (entry) {
 				php_error_docref(entry->docref, entry->severity, "%s", ZSTR_VAL(entry->message));
 				entry = entry->next;
@@ -416,7 +416,7 @@ static void php_stream_report_errors(const php_stream_context *context, const ph

 		case PHP_STREAM_ERROR_MODE_EXCEPTION: {
 			if (is_terminating) {
-				php_stream_throw_exception_with_errors(op);
+				php_stream_throw_exception_with_errors(first_error);
 			}
 			break;
 		}
@@ -431,7 +431,7 @@ static void php_stream_report_errors(const php_stream_context *context, const ph

 	if (handler) {
 		zval errors_array;
-		php_stream_error_create_array(&errors_array, op->first_error);
+		php_stream_error_create_array(&errors_array, first_error);

 		php_stream_call_error_handler(handler, &errors_array);

@@ -450,93 +450,101 @@ PHPAPI void php_stream_error_operation_end(const php_stream_context *context)
 		return;
 	}

-	if (op->error_count > 0) {
-		if (context == NULL) {
-			context = FG(default_context);
-		}
+	php_stream_error_entry *first_error = op->first_error;
+	op->first_error = NULL;
+	op->last_error = NULL;
+	op->error_count = 0;

-		php_stream_error_mode error_mode = php_stream_get_error_mode(context);
-		php_stream_error_store store_mode = php_stream_get_error_store_mode(context, error_mode);
+	state->operation_depth--;
+	state->current_operation = php_stream_get_parent_operation();

-		bool is_terminating = php_stream_has_terminating_error(op);
+	if (!first_error) {
+		return;
+	}

-		if (context) {
-			GC_ADDREF(context->res);
-		}
-		php_stream_report_errors(context, op, error_mode, is_terminating);
-		if (context) {
-			zend_list_delete(context->res);
-		}
+	if (context == NULL) {
+		context = FG(default_context);
+	}

-		if (store_mode == PHP_STREAM_ERROR_STORE_NONE) {
-			php_stream_error_entry_free(op->first_error);
-			op->first_error = NULL;
-		} else {
-			php_stream_error_entry *entry = op->first_error;
-			php_stream_error_entry *prev = NULL;
-			php_stream_error_entry *to_store_first = NULL;
-			php_stream_error_entry *to_store_last = NULL;
-			uint32_t to_store_count = 0;
-			php_stream_error_entry *remaining_first = NULL;
+	php_stream_error_mode error_mode = php_stream_get_error_mode(context);
+	php_stream_error_store store_mode = php_stream_get_error_store_mode(context, error_mode);

-			while (entry) {
-				php_stream_error_entry *next = entry->next;
-				bool should_store = false;
-
-				if (store_mode == PHP_STREAM_ERROR_STORE_ALL) {
-					should_store = true;
-				} else if (store_mode == PHP_STREAM_ERROR_STORE_NON_TERM && !entry->terminating) {
-					should_store = true;
-				} else if (store_mode == PHP_STREAM_ERROR_STORE_TERMINAL && entry->terminating) {
-					should_store = true;
-				}
+	bool is_terminating = php_stream_has_terminating_error(first_error);

-				if (should_store) {
-					entry->next = NULL;
-					if (to_store_last) {
-						to_store_last->next = entry;
-					} else {
-						to_store_first = entry;
-					}
-					to_store_last = entry;
-					to_store_count++;
-				} else {
-					entry->next = NULL;
-					if (prev) {
-						prev->next = entry;
-					} else {
-						remaining_first = entry;
-					}
-					prev = entry;
-				}
+	if (context) {
+		GC_ADDREF(context->res);
+	}

-				entry = next;
-			}
+	uint32_t saved_floor = state->operation_floor;
+	state->operation_floor = state->operation_depth;
+	state->current_operation = NULL;
+	php_stream_report_errors(context, first_error, error_mode, is_terminating);
+	state->operation_floor = saved_floor;
+	state->current_operation = php_stream_get_parent_operation();

-			if (to_store_first) {
-				php_stream_stored_error *stored = emalloc(sizeof(php_stream_stored_error));
-				stored->first_error = to_store_first;
-				stored->error_count = to_store_count;
-				stored->next = state->stored_errors;
+	if (context) {
+		zend_list_delete(context->res);
+	}

-				state->stored_errors = stored;
-				state->stored_count++;
-			}
+	if (store_mode == PHP_STREAM_ERROR_STORE_NONE) {
+		php_stream_error_entry_free(first_error);
+		return;
+	}

-			if (remaining_first) {
-				php_stream_error_entry_free(remaining_first);
-			}
+	php_stream_error_entry *entry = first_error;
+	php_stream_error_entry *prev = NULL;
+	php_stream_error_entry *to_store_first = NULL;
+	php_stream_error_entry *to_store_last = NULL;
+	uint32_t to_store_count = 0;
+	php_stream_error_entry *remaining_first = NULL;
+
+	while (entry) {
+		php_stream_error_entry *next = entry->next;
+		bool should_store = false;
+
+		if (store_mode == PHP_STREAM_ERROR_STORE_ALL) {
+			should_store = true;
+		} else if (store_mode == PHP_STREAM_ERROR_STORE_NON_TERM && !entry->terminating) {
+			should_store = true;
+		} else if (store_mode == PHP_STREAM_ERROR_STORE_TERMINAL && entry->terminating) {
+			should_store = true;
+		}

-			op->first_error = NULL;
+		if (should_store) {
+			entry->next = NULL;
+			if (to_store_last) {
+				to_store_last->next = entry;
+			} else {
+				to_store_first = entry;
+			}
+			to_store_last = entry;
+			to_store_count++;
+		} else {
+			entry->next = NULL;
+			if (prev) {
+				prev->next = entry;
+			} else {
+				remaining_first = entry;
+			}
+			prev = entry;
 		}
+
+		entry = next;
 	}

-	state->operation_depth--;
-	state->current_operation = php_stream_get_parent_operation();
+	if (to_store_first) {
+		php_stream_stored_error *stored = emalloc(sizeof(php_stream_stored_error));
+		stored->first_error = to_store_first;
+		stored->error_count = to_store_count;
+		stored->next = state->stored_errors;

-	op->first_error = NULL;
-	op->last_error = NULL;
-	op->error_count = 0;
+		state->stored_errors = stored;
+		state->stored_count++;
+	}
+
+	if (remaining_first) {
+		php_stream_error_entry_free(remaining_first);
+	}
 }

 PHPAPI void php_stream_error_operation_end_for_stream(const php_stream *stream)