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 &&