Commit ff03f03b826 for php.net

commit ff03f03b8264f9acf9ff06a12b2970987e4685e3
Author: Jakub Zelenka <bukka@php.net>
Date:   Tue Sep 15 18:25:26 2026 +0200

    main/streams: Fix parent lookup when unwinding stream error operations

diff --git a/ext/standard/tests/streams/stream_errors_nested_operation_unwind.phpt b/ext/standard/tests/streams/stream_errors_nested_operation_unwind.phpt
new file mode 100644
index 00000000000..13cd22b01d5
--- /dev/null
+++ b/ext/standard/tests/streams/stream_errors_nested_operation_unwind.phpt
@@ -0,0 +1,83 @@
+--TEST--
+Stream errors: nested operation unwinding restores the parent 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 NestingStream
+{
+    public $context;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        $inner = fopen('php://memory', 'r');
+        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('nesting-stream', NestingStream::class);
+
+$busy = false;
+set_error_handler(static function (int $severity, string $message) use (&$busy): bool {
+    echo "handler: $message\n";
+    if ($busy) {
+        return true;
+    }
+    $busy = true;
+    $s = fopen('nesting-stream://x', 'r');
+    fread($s, 1);
+    fclose($s);
+    $busy = false;
+    return true;
+});
+
+$stream = fopen('error-stream://x', 'r');
+var_dump(fread($stream, 1));
+fclose($stream);
+echo "done\n";
+?>
+--EXPECT--
+handler: fread(): ErrorStream::stream_read - read 1 bytes more data than requested (8193 read, 8192 max) - excess data will be lost
+string(1) "A"
+done
diff --git a/ext/standard/tests/streams/stream_errors_wrapper_nested_operation.phpt b/ext/standard/tests/streams/stream_errors_wrapper_nested_operation.phpt
new file mode 100644
index 00000000000..383cb56a6b0
--- /dev/null
+++ b/ext/standard/tests/streams/stream_errors_wrapper_nested_operation.phpt
@@ -0,0 +1,94 @@
+--TEST--
+Stream errors: wrapper errors after a nested stream operation are still reported
+--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 ProxyStream
+{
+    public $context;
+    private int $reads = 0;
+
+    public function stream_open($path, $mode, $options, &$openedPath): bool
+    {
+        return true;
+    }
+
+    public function stream_read(int $count): string
+    {
+        $this->reads++;
+        if ($this->reads === 2) {
+            $inner = fopen('error-stream://x', 'r', false, $GLOBALS['innerCtx']);
+            fread($inner, 1);
+            fclose($inner);
+        }
+        return $this->reads <= 2 ? str_repeat('B', $count + 1) : '';
+    }
+
+    public function stream_eof(): bool
+    {
+        return $this->reads >= 3;
+    }
+
+    public function stream_stat(): array
+    {
+        return [];
+    }
+}
+
+stream_wrapper_register('error-stream', ErrorStream::class);
+stream_wrapper_register('proxy', ProxyStream::class);
+
+$innerCtx = stream_context_create(['stream' => [
+    'error_mode' => StreamErrorMode::Silent,
+    'error_handler' => static function (array $errors): void {
+        echo "inner handler: " . count($errors) . " error(s): {$errors[0]->code->name}\n";
+    },
+]]);
+
+$outerCtx = stream_context_create(['stream' => [
+    'error_mode' => StreamErrorMode::Silent,
+    'error_store' => StreamErrorStore::All,
+    'error_handler' => static function (array $errors): void {
+        echo "outer handler: " . count($errors) . " error(s)\n";
+        foreach ($errors as $error) {
+            echo "  {$error->code->name}: {$error->message}\n";
+        }
+    },
+]]);
+
+$stream = fopen('proxy://x', 'r', false, $outerCtx);
+var_dump(strlen(stream_get_contents($stream)));
+fclose($stream);
+var_dump(count(stream_last_errors()));
+?>
+--EXPECT--
+inner handler: 1 error(s): UserspaceInvalidReturn
+outer handler: 2 error(s)
+  UserspaceInvalidReturn: ProxyStream::stream_read - read 1 bytes more data than requested (8193 read, 8192 max) - excess data will be lost
+  UserspaceInvalidReturn: ProxyStream::stream_read - read 1 bytes more data than requested (8193 read, 8192 max) - excess data will be lost
+int(16384)
+int(2)
diff --git a/main/streams/stream_errors.c b/main/streams/stream_errors.c
index 73f3313ce1e..94a0b5692ee 100644
--- a/main/streams/stream_errors.c
+++ b/main/streams/stream_errors.c
@@ -207,11 +207,11 @@ 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 < 1) {
 		return NULL;
 	}

-	return php_stream_get_operation_at_depth(state->operation_depth - 2);
+	return php_stream_get_operation_at_depth(state->operation_depth - 1);
 }

 /* Clean up functions */