Commit 1da60a2a392 for php.net

commit 1da60a2a392ab8fb608a47a62cb6b4eaffc51b59
Author: David Carlier <devnexen@gmail.com>
Date:   Fri Jul 24 15:01:40 2026 +0100

    streams: php_stream_copy_to_stream_ex() drops progress notifications.

    The fd-level copy fast path hands the transfer to the kernel, so the
    bytes bypassed php_sockop_read() and neither the progress increments nor
    the completion event were emitted. Skip the fast path when either stream
    carries a context notifier.

    Fix GH-22841
    Close GH-22863

diff --git a/NEWS b/NEWS
index 63e4fdf0c24..79a15820a90 100644
--- a/NEWS
+++ b/NEWS
@@ -63,6 +63,8 @@ PHP                                                                        NEWS
   . Added a new IO copy API used by php_stream_copy_to_stream_ex() that
     leverages platform primitives (sendfile, splice, copy_file_range,
     TransmitFile) for faster stream copying. (Jakub Zelenka, David Carlier)
+  . Fixed bug GH-22841 (php_stream_copy_to_stream_ex() drops progress
+    notifications when using the copy fast path). (David Carlier)

 16 Jul 2026, PHP 8.6.0alpha2

diff --git a/ext/standard/tests/streams/gh22841.phpt b/ext/standard/tests/streams/gh22841.phpt
new file mode 100644
index 00000000000..0f03bf83da2
--- /dev/null
+++ b/ext/standard/tests/streams/gh22841.phpt
@@ -0,0 +1,68 @@
+--TEST--
+GH-22841 (php_stream_copy_to_stream_ex() drops progress notifications)
+--EXTENSIONS--
+zlib
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip proc_open() is not available");
+?>
+--INI--
+allow_url_fopen=1
+--CONFLICTS--
+server
+--FILE--
+<?php
+
+/* A 43 byte gzip stream decompressing to "Hello World!\n". Kept as a literal so
+ * that the server, which is started with -n, does not need zlib itself. */
+$serverCode = <<<'CODE'
+$data = base64_decode("H4sICPQfX2oAA2luZGV4LnBocADzSM3JyVcIzy/KSVHkAgDd3RR9DQAAAA==");
+header("Content-Type: application/gzip");
+header("Content-Length: " . strlen($data));
+echo $data;
+CODE;
+
+include __DIR__ . "/../../../../sapi/cli/tests/php_cli_server.inc";
+php_cli_server_start($serverCode, null, []);
+
+$names = [
+    STREAM_NOTIFY_CONNECT => "CONNECT",
+    STREAM_NOTIFY_MIME_TYPE_IS => "MIME_TYPE_IS",
+    STREAM_NOTIFY_FILE_SIZE_IS => "FILE_SIZE_IS",
+    STREAM_NOTIFY_PROGRESS => "PROGRESS",
+    STREAM_NOTIFY_COMPLETED => "COMPLETED",
+];
+
+$events = [];
+$context = stream_context_create();
+stream_context_set_params($context, ["notification" =>
+    function ($code, $severity, $message, $message_code, $bytes_transferred, $bytes_max)
+    use (&$events, $names) {
+        $name = $names[$code] ?? $code;
+        $event = $name . " " . ($code === STREAM_NOTIFY_FILE_SIZE_IS ? $bytes_max : $bytes_transferred);
+        /* The body may arrive in more than one read, so collapse consecutive
+         * progress events into the latest one to keep the output stable. */
+        if ($name === "PROGRESS" && str_starts_with(end($events) ?: "", "PROGRESS ")) {
+            array_pop($events);
+        }
+        $events[] = $event;
+    }
+]);
+
+/* compress.zlib:// opens the inner stream with STREAM_MUST_SEEK, which copies
+ * the HTTP stream into a temporary file through php_stream_copy_to_stream_ex(). */
+$fp = fopen("compress.zlib://http://" . PHP_CLI_SERVER_ADDRESS . "/", "r", false, $context);
+var_dump(stream_get_contents($fp));
+fclose($fp);
+
+echo implode(PHP_EOL, $events), PHP_EOL;
+
+?>
+--EXPECT--
+string(13) "Hello World!
+"
+CONNECT 0
+MIME_TYPE_IS 0
+FILE_SIZE_IS 43
+PROGRESS 43
+COMPLETED 43
diff --git a/main/streams/streams.c b/main/streams/streams.c
index 3d8830d7291..7d0171c1ca4 100644
--- a/main/streams/streams.c
+++ b/main/streams/streams.c
@@ -1302,7 +1302,12 @@ static zend_result php_stream_filters_seek_all(php_stream *stream, bool is_start
 	return SUCCESS;
 }

-
+static bool php_stream_has_notifier(php_stream *stream)
+{
+	php_stream_context *context = PHP_STREAM_CONTEXT(stream);
+	/* The fd-level copy cannot emit progress notifications. */
+	return context && context->notifier;
+}

 PHPAPI int _php_stream_seek(php_stream *stream, zend_off_t offset, int whence)
 {
@@ -1655,7 +1660,8 @@ PHPAPI zend_result _php_stream_copy_to_stream_ex(php_stream *src, php_stream *de
 	 * are empty, so the fd offsets match the logical stream positions */
 	if (!php_stream_is(src, PHP_STREAM_IS_USERSPACE) && !php_stream_is(dest, PHP_STREAM_IS_USERSPACE) &&
 			src->writepos == src->readpos && dest->writepos == dest->readpos &&
-			!php_stream_is_filtered(src) && !php_stream_is_filtered(dest)) {
+			!php_stream_is_filtered(src) && !php_stream_is_filtered(dest) &&
+			!php_stream_has_notifier(src) && !php_stream_has_notifier(dest)) {
 		php_io_fd src_copy_fd, dest_copy_fd;

 		if (php_stream_cast(src, PHP_STREAM_AS_FD_FOR_COPY, (void *) &src_copy_fd, 0) == SUCCESS &&