Commit 32a0f3b6cfd for php.net

commit 32a0f3b6cfdee8ae4781121e864fd0b1837ecbe4
Author: Jakub Zelenka <bukka@php.net>
Date:   Sun Jul 19 17:15:16 2026 +0200

    IO copy API for stream copying (#20399)

    This introduces new API for fd copying and modifies
    php_stream_copy_to_stream_ex to use it. The implementation is separated
    for various platforms and the end result have couple of implications:

    - sendfile is used for copying file to generic fd (e.g. sockets) on all
      platforms except Windows that use TransmitFile
    - splice is used for copying between generic fds (e.g. sockets) on
      Linux
    - copy_file_range should get used on alpine linux with directly using
      syscall (as musl does not seem to implement it)
    - copy_file_range is used in the loop so it is used multiple times for
      files bigger than 2GB on Linux.
    - file mmap for copying is removed as it allowed crashing PHP when
      another process modified mapped file - this was used as a fallback
      for file copying. Sendfile should partially replace it.
    - File to file copying was optimized on Windows with use of ReadFile
      and WriteFile.

    This also adds various tests including Linux unit tests.

    Closes GH-20399

    Co-authored-by: David Carlier <devnexen@gmail.com>

diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 6338a1cb945..fa79580f42e 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -3,6 +3,8 @@ on:
   push:
     paths:
       - 'main/network.c'
+      - 'main/io/**'
+      - 'main/php_io.h'
       - 'tests/unit/**'
       - '.github/workflows/unit-tests.yml'
     branches:
@@ -10,6 +12,8 @@ on:
   pull_request:
     paths:
       - 'main/network.c'
+      - 'main/io/**'
+      - 'main/php_io.h'
       - 'tests/unit/**'
       - '.github/workflows/unit-tests.yml'
     branches:
diff --git a/NEWS b/NEWS
index 6e9921e18dc..af7d45ecd08 100644
--- a/NEWS
+++ b/NEWS
@@ -35,6 +35,11 @@ PHP                                                                        NEWS
     TCP_USER_TIMEOUT, and SO_LINGER options. (Weilin Du)
   . Fixed various memory related issues in ext/sockets. (David Carlier)

+- Streams:
+  . 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)
+
 16 Jul 2026, PHP 8.6.0alpha2

 - Core:
diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS
index 7b7b7c721af..72ee0a1b2be 100644
--- a/UPGRADING.INTERNALS
+++ b/UPGRADING.INTERNALS
@@ -136,6 +136,10 @@ PHP 8.6 INTERNALS UPGRADE NOTES
   . Added zend_compile_ast().
   . Added zend_check_type_ex().
   . Added zend_create_partial_closure().
+  . Added a new IO copy API in <php_io.h>. php_io_copy() copies bytes between
+    file descriptors using the most efficient platform primitive available
+    (sendfile, splice, copy_file_range, TransmitFile), and is now used by
+    php_stream_copy_to_stream_ex(). The mmap-based copy fallback was removed.

 ========================
 2. Build system changes
diff --git a/configure.ac b/configure.ac
index b50339063fc..ec64637b186 100644
--- a/configure.ac
+++ b/configure.ac
@@ -369,6 +369,9 @@ AC_SEARCH_LIBS([Pgrab], [proc])
 dnl Haiku does not have network api in libc.
 AC_SEARCH_LIBS([setsockopt], [network])

+dnl Solaris/illumos provide sendfile() in libsendfile; libc on Linux/FreeBSD.
+AC_SEARCH_LIBS([sendfile], [sendfile])
+
 dnl Check for openpty. It may require linking against libutil or libbsd.
 AC_CHECK_FUNCS([openpty],,
   [AC_SEARCH_LIBS([openpty], [util bsd], [AC_DEFINE([HAVE_OPENPTY], [1])])])
@@ -588,10 +591,12 @@ AC_CHECK_FUNCS(m4_normalize([
   putenv
   reallocarray
   scandir
+  sendfile
   setenv
   setitimer
   shutdown
   sigprocmask
+  splice
   statfs
   statvfs
   std_syslog
@@ -1685,6 +1690,15 @@ PHP_ADD_SOURCES_X([main],
   [PHP_FASTCGI_OBJS],
   [no])

+PHP_ADD_SOURCES([main/io], m4_normalize([
+    php_io.c
+    php_io_copy_linux.c
+    php_io_copy_freebsd.c
+    php_io_copy_solaris.c
+    php_io_copy_macos.c
+  ]),
+  [-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1])
+
 PHP_ADD_SOURCES([main/poll], m4_normalize([
     poll_backend_epoll.c
     poll_backend_eventport.c
diff --git a/ext/openssl/tests/stream_copy_to_stream_ssl_to_file.phpt b/ext/openssl/tests/stream_copy_to_stream_ssl_to_file.phpt
new file mode 100644
index 00000000000..76684d2917b
--- /dev/null
+++ b/ext/openssl/tests/stream_copy_to_stream_ssl_to_file.phpt
@@ -0,0 +1,71 @@
+--TEST--
+stream_copy_to_stream() from a TLS stream copies decrypted data (no fd fast-path)
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'stream_copy_ssl.pem.tmp';
+$cacertFile = __DIR__ . DIRECTORY_SEPARATOR . 'stream_copy_ssl-ca.pem.tmp';
+
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'local_cert' => '%s',
+    ]]);
+    $flags = STREAM_SERVER_BIND | STREAM_SERVER_LISTEN;
+    $server = stream_socket_server("ssl://127.0.0.1:0", $errno, $errstr, $flags, $serverCtx);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server, 5);
+    fwrite($conn, str_repeat("secret-", 1000));
+    fclose($conn);
+    fclose($server);
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$peerName = 'stream_copy_ssl_peer';
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'verify_peer' => true,
+        'cafile' => '%s',
+        'peer_name' => '%s',
+    ]]);
+    $client = stream_socket_client("ssl://{{ ADDR }}", $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $clientCtx);
+
+    $tmp = tmpfile();
+    /* If the copy offloaded the raw socket fd it would write ciphertext; the
+     * decrypted plaintext proves it correctly fell back to the userspace loop. */
+    $copied = stream_copy_to_stream($client, $tmp);
+    var_dump($copied);
+
+    fseek($tmp, 0, SEEK_SET);
+    $content = stream_get_contents($tmp);
+    var_dump(strlen($content));
+    var_dump($content === str_repeat("secret-", 1000));
+
+    fclose($tmp);
+    fclose($client);
+CODE;
+$clientCode = sprintf($clientCode, $cacertFile, $peerName);
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveCaCert($cacertFile);
+$certificateGenerator->saveNewCertAsFileWithKey($peerName, $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'stream_copy_ssl.pem.tmp');
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'stream_copy_ssl-ca.pem.tmp');
+?>
+--EXPECT--
+int(7000)
+int(7000)
+bool(true)
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index d4158634e5b..f2adfc49075 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -27,7 +27,7 @@
 #include "zend_exceptions.h"
 #include "php_openssl.h"
 #include "php_openssl_backend.h"
-#include "php_network.h"
+#include "php_io.h"
 #include <openssl/ssl.h>
 #include <openssl/rsa.h>
 #include <openssl/x509.h>
@@ -3627,6 +3627,18 @@ static int php_openssl_sockop_cast(php_stream *stream, int castas, void **ret)
 				*(php_socket_t *)ret = sslsock->s.socket;
 			}
 			return SUCCESS;
+		case PHP_STREAM_AS_FD_FOR_COPY:
+			if (sslsock->ssl_active) {
+				return FAILURE;
+			}
+			if (ret) {
+				php_io_fd *copy_fd = (php_io_fd *) ret;
+				copy_fd->socket = sslsock->s.socket;
+				copy_fd->fd_type = PHP_IO_FD_SOCKET;
+				copy_fd->timeout = sslsock->s.timeout;
+				copy_fd->is_blocked = sslsock->s.is_blocked;
+			}
+			return SUCCESS;
 		default:
 			return FAILURE;
 	}
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_append.phpt b/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_append.phpt
new file mode 100644
index 00000000000..efcec5dafbb
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_append.phpt
@@ -0,0 +1,34 @@
+--TEST--
+stream_copy_to_stream() file to file with an append-mode destination
+--FILE--
+<?php
+
+$srcFile = __DIR__ . '/stream_copy_append_src.txt';
+$dstFile = __DIR__ . '/stream_copy_append_dst.txt';
+
+file_put_contents($srcFile, str_repeat("b", 3000));
+file_put_contents($dstFile, "PREFIX-");
+
+$src = fopen($srcFile, 'r');
+/* O_APPEND must disable the fd-level copy fast-path and still append correctly. */
+$dst = fopen($dstFile, 'a');
+
+$copied = stream_copy_to_stream($src, $dst);
+var_dump($copied);
+
+fclose($src);
+fclose($dst);
+
+$result = file_get_contents($dstFile);
+var_dump(strlen($result));
+var_dump($result === "PREFIX-" . str_repeat("b", 3000));
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/stream_copy_append_src.txt');
+@unlink(__DIR__ . '/stream_copy_append_dst.txt');
+?>
+--EXPECT--
+int(3000)
+int(3007)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_dest_read_ahead.phpt b/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_dest_read_ahead.phpt
new file mode 100644
index 00000000000..e772b589248
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_dest_read_ahead.phpt
@@ -0,0 +1,38 @@
+--TEST--
+stream_copy_to_stream() file to file with a partially read destination
+--FILE--
+<?php
+
+$srcFile = __DIR__ . '/stream_copy_dest_read_ahead_src.txt';
+$dstFile = __DIR__ . '/stream_copy_dest_read_ahead_dst.txt';
+
+file_put_contents($srcFile, str_repeat("N", 50));
+file_put_contents($dstFile, str_repeat("O", 3000));
+
+$src = fopen($srcFile, 'r');
+$dst = fopen($dstFile, 'r+');
+/* Buffered read-ahead moves the fd offset past the stream position; the copy
+ * must land at the stream position. */
+fread($dst, 10);
+
+$copied = stream_copy_to_stream($src, $dst);
+var_dump($copied);
+var_dump(ftell($dst));
+
+fclose($src);
+fclose($dst);
+
+$result = file_get_contents($dstFile);
+var_dump(strlen($result));
+var_dump($result === str_repeat("O", 10) . str_repeat("N", 50) . str_repeat("O", 2940));
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/stream_copy_dest_read_ahead_src.txt');
+@unlink(__DIR__ . '/stream_copy_dest_read_ahead_dst.txt');
+?>
+--EXPECT--
+int(50)
+int(60)
+int(3000)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_over_2gb.phpt b/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_over_2gb.phpt
new file mode 100644
index 00000000000..b8563c3c57b
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_file_to_file_over_2gb.phpt
@@ -0,0 +1,44 @@
+--TEST--
+stream_copy_to_stream() copies files larger than 2GB in full
+--SKIPIF--
+<?php
+if (!getenv('RUN_RESOURCE_HEAVY_TESTS')) die('skip resource-heavy test');
+if (PHP_INT_SIZE < 8) die('skip 64-bit only');
+if (getenv('SKIP_SLOW_TESTS')) die('skip slow test');
+$dir = sys_get_temp_dir();
+if (disk_free_space($dir) < 4 * 1024 * 1024 * 1024) {
+    die('skip Reason: Insufficient disk space (less than 4GB)');
+}
+?>
+--FILE--
+<?php
+$size = 3 * 1024 * 1024 * 1024; // exceeds the ~2GB per-call kernel copy limit
+$src = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "stream_copy_over_2gb_src.bin";
+$dst = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "stream_copy_over_2gb_dst.bin";
+
+// Create a sparse 3GB source so the copy loop runs without using 3GB of data.
+$fh = fopen($src, "wb");
+fseek($fh, $size - 1);
+fwrite($fh, "\0");
+fclose($fh);
+
+$in = fopen($src, "rb");
+$out = fopen($dst, "wb");
+$copied = stream_copy_to_stream($in, $out);
+fclose($in);
+fclose($out);
+
+var_dump($copied === $size);
+var_dump(filesize($dst) === $size);
+
+unlink($src);
+unlink($dst);
+?>
+--EXPECT--
+bool(true)
+bool(true)
+--CLEAN--
+<?php
+@unlink(sys_get_temp_dir() . DIRECTORY_SEPARATOR . "stream_copy_over_2gb_src.bin");
+@unlink(sys_get_temp_dir() . DIRECTORY_SEPARATOR . "stream_copy_over_2gb_dst.bin");
+?>
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_maxlen.phpt b/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_maxlen.phpt
new file mode 100644
index 00000000000..dc9e52a721d
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_maxlen.phpt
@@ -0,0 +1,57 @@
+--TEST--
+stream_copy_to_stream() file to socket with a maxlength shorter than the file (bounded sendfile + source offset)
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    $result = stream_get_contents($conn);
+
+    phpt_notify(message: strlen($result));
+    phpt_notify(message: $result === str_repeat("A", 8192) ? "match" : "mismatch");
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $src = tmpfile();
+    fwrite($src, str_repeat("A", 8192) . str_repeat("B", 8192));
+    rewind($src);
+
+    $dest = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+
+    /* Only the first 8192 bytes must be sent: the bounded sendfile path has to
+     * stop at maxlen rather than streaming to EOF. */
+    $copied = stream_copy_to_stream($src, $dest, 8192);
+    var_dump($copied);
+
+    /* The source position must have advanced by exactly maxlen, so the kernel
+     * offload restored the descriptor offset to the maxlen boundary. */
+    $rest = fread($src, 8192);
+    var_dump(strlen($rest));
+    var_dump($rest === str_repeat("B", 8192));
+
+    fclose($dest);
+    fclose($src);
+
+    var_dump((int) trim(phpt_wait()));
+    var_dump(trim(phpt_wait()) === "match");
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+int(8192)
+int(8192)
+bool(true)
+int(8192)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_medium.phpt b/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_medium.phpt
new file mode 100644
index 00000000000..c7bd9afeeda
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_medium.phpt
@@ -0,0 +1,48 @@
+--TEST--
+stream_copy_to_stream() 16k with file as $source and socket as $dest
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    $data = str_repeat('data', 4096);
+    $result = stream_get_contents($conn);
+
+    phpt_notify(message: strlen($result));
+    phpt_notify(message: $result === $data ? "match" : "mismatch");
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $src = tmpfile();
+    $data = str_repeat('data', 4096);
+    fwrite($src, $data);
+    rewind($src);
+
+    $dest = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+    $copied = stream_copy_to_stream($src, $dest);
+    var_dump($copied);
+
+    fclose($dest);
+    fclose($src);
+
+    var_dump((int) trim(phpt_wait()));
+    var_dump(trim(phpt_wait()) === "match");
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+int(16384)
+int(16384)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_over_eof.phpt b/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_over_eof.phpt
new file mode 100644
index 00000000000..7834e9420a7
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_file_to_socket_over_eof.phpt
@@ -0,0 +1,53 @@
+--TEST--
+stream_copy_to_stream() file to socket with a maxlength larger than the file (sendfile stops at EOF)
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    $result = stream_get_contents($conn);
+
+    phpt_notify(message: strlen($result));
+    phpt_notify(message: $result === str_repeat("A", 4096) ? "match" : "mismatch");
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $src = tmpfile();
+    fwrite($src, str_repeat("A", 4096));
+    rewind($src);
+
+    $dest = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+
+    /* maxlen exceeds the file size: sendfile must stop at EOF and report only
+     * the bytes actually available rather than blocking for the full maxlen. */
+    $copied = stream_copy_to_stream($src, $dest, 100000);
+    var_dump($copied);
+
+    /* Nothing left to read once the whole file has been consumed. */
+    var_dump(strlen(fread($src, 4096)));
+
+    fclose($dest);
+    fclose($src);
+
+    var_dump((int) trim(phpt_wait()));
+    var_dump(trim(phpt_wait()) === "match");
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+int(4096)
+int(0)
+int(4096)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_pipe_to_file.phpt b/ext/standard/tests/streams/stream_copy_to_stream_pipe_to_file.phpt
new file mode 100644
index 00000000000..1ef85a7b21a
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_pipe_to_file.phpt
@@ -0,0 +1,37 @@
+--TEST--
+stream_copy_to_stream() with a pipe as $source and file as $dest
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$descriptors = [1 => ['pipe', 'w']];
+$proc = proc_open(
+    [PHP_BINARY, '-n', '-r', 'echo str_repeat("p", 5000);'],
+    $descriptors,
+    $pipes
+);
+var_dump(is_resource($proc));
+
+$source = $pipes[1];
+$tmp = tmpfile();
+
+$copied = stream_copy_to_stream($source, $tmp);
+var_dump($copied);
+
+fseek($tmp, 0, SEEK_SET);
+$content = stream_get_contents($tmp);
+var_dump(strlen($content));
+var_dump($content === str_repeat("p", 5000));
+
+fclose($tmp);
+fclose($source);
+proc_close($proc);
+?>
+--EXPECT--
+bool(true)
+int(5000)
+int(5000)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_pipe_to_socket.phpt b/ext/standard/tests/streams/stream_copy_to_stream_pipe_to_socket.phpt
new file mode 100644
index 00000000000..c7a9b9293e4
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_pipe_to_socket.phpt
@@ -0,0 +1,54 @@
+--TEST--
+stream_copy_to_stream() with a pipe as $source and a socket as $dest (splice from pipe)
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    $result = stream_get_contents($conn);
+
+    phpt_notify(message: strlen($result));
+    phpt_notify(message: $result === str_repeat("p", 5000) ? "match" : "mismatch");
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    /* A real pipe as the source exercises the splice-from-pipe path; with a
+     * socket destination it must use the corked (SPLICE_F_MORE) variant. */
+    $descriptors = [1 => ['pipe', 'w']];
+    $proc = proc_open(
+        [PHP_BINARY, '-n', '-r', 'echo str_repeat("p", 5000);'],
+        $descriptors,
+        $pipes
+    );
+
+    $source = $pipes[1];
+    $dest = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+
+    $copied = stream_copy_to_stream($source, $dest);
+    var_dump($copied);
+
+    fclose($dest);
+    fclose($source);
+    proc_close($proc);
+
+    var_dump((int) trim(phpt_wait()));
+    var_dump(trim(phpt_wait()) === "match");
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+int(5000)
+int(5000)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_socket.phpt b/ext/standard/tests/streams/stream_copy_to_stream_socket.phpt
deleted file mode 100644
index dafe90e40c4..00000000000
--- a/ext/standard/tests/streams/stream_copy_to_stream_socket.phpt
+++ /dev/null
@@ -1,30 +0,0 @@
---TEST--
-stream_copy_to_stream() with socket as $source
---SKIPIF--
-<?php
-$sockets = @stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
-if (!$sockets) die("skip stream_socket_pair");
-?>
---FILE--
-<?php
-
-$sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
-$tmp = tmpfile();
-
-fwrite($sockets[0], "a");
-stream_socket_shutdown($sockets[0], STREAM_SHUT_WR);
-stream_copy_to_stream($sockets[1], $tmp);
-
-fseek($tmp, 0, SEEK_SET);
-var_dump(stream_get_contents($tmp));
-
-stream_copy_to_stream($sockets[1], $tmp);
-
-fseek($tmp, 0, SEEK_SET);
-var_dump(stream_get_contents($tmp));
-
-
-?>
---EXPECT--
-string(1) "a"
-string(1) "a"
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_socket_empty.phpt b/ext/standard/tests/streams/stream_copy_to_stream_socket_empty.phpt
new file mode 100644
index 00000000000..a0cf3e61bca
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_socket_empty.phpt
@@ -0,0 +1,23 @@
+--TEST--
+stream_copy_to_stream() from a socket already at EOF returns 0, not false
+--SKIPIF--
+<?php
+if (substr(PHP_OS, 0, 3) == 'WIN') die('skip not for Windows');
+?>
+--FILE--
+<?php
+$file = tempnam(sys_get_temp_dir(), 'sct');
+$dest = fopen($file, 'wb');
+
+[$a, $b] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
+fclose($b); // peer closed without sending; $a is at EOF
+
+$copied = stream_copy_to_stream($a, $dest);
+var_dump($copied);
+
+fclose($a);
+fclose($dest);
+@unlink($file);
+?>
+--EXPECT--
+int(0)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_large.phpt b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_large.phpt
new file mode 100644
index 00000000000..6d66bf4f81b
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_large.phpt
@@ -0,0 +1,42 @@
+--TEST--
+stream_copy_to_stream() 200k bytes with socket as $source and file as $dest
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    fwrite($conn, str_repeat("a", 200000));
+    stream_socket_shutdown($conn, STREAM_SHUT_WR);
+
+    /* Keep alive until client is done reading. */
+    fread($conn, 1);
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $source = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+    $tmp = tmpfile();
+
+    stream_copy_to_stream($source, $tmp);
+
+    fseek($tmp, 0, SEEK_SET);
+    var_dump(strlen(stream_get_contents($tmp)));
+
+    fclose($tmp);
+    fclose($source);
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+int(200000)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_maxlen.phpt b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_maxlen.phpt
new file mode 100644
index 00000000000..13fcc475059
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_maxlen.phpt
@@ -0,0 +1,48 @@
+--TEST--
+stream_copy_to_stream() socket to file with a maxlength shorter than the data
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    fwrite($conn, str_repeat("a", 10000));
+    stream_socket_shutdown($conn, STREAM_SHUT_WR);
+
+    /* Keep alive until client is done reading. */
+    fread($conn, 1);
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $source = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+    $tmp = tmpfile();
+
+    /* Only 4096 of the 10000 available bytes must be copied. */
+    $copied = stream_copy_to_stream($source, $tmp, 4096);
+    var_dump($copied);
+
+    fseek($tmp, 0, SEEK_SET);
+    $content = stream_get_contents($tmp);
+    var_dump(strlen($content));
+    var_dump($content === str_repeat("a", 4096));
+
+    fclose($tmp);
+    fclose($source);
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+int(4096)
+int(4096)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_single.phpt b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_single.phpt
new file mode 100644
index 00000000000..a5b694ce565
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_single.phpt
@@ -0,0 +1,49 @@
+--TEST--
+stream_copy_to_stream() single byte with socket as $source and file as $dest
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    fwrite($conn, "a");
+    stream_socket_shutdown($conn, STREAM_SHUT_WR);
+
+    /* Keep alive until client is done reading. */
+    fread($conn, 1);
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $source = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+    $tmp = tmpfile();
+
+    stream_copy_to_stream($source, $tmp);
+
+    fseek($tmp, 0, SEEK_SET);
+    var_dump(stream_get_contents($tmp));
+
+    /* Second copy after EOF should be a no-op. */
+    stream_copy_to_stream($source, $tmp);
+
+    fseek($tmp, 0, SEEK_SET);
+    var_dump(stream_get_contents($tmp));
+
+    fclose($tmp);
+    fclose($source);
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+string(1) "a"
+string(1) "a"
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_small.phpt b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_small.phpt
new file mode 100644
index 00000000000..9b4f8befce9
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_file_small.phpt
@@ -0,0 +1,42 @@
+--TEST--
+stream_copy_to_stream() 2048 bytes with socket as $source and file as $dest
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    fwrite($conn, str_repeat("a", 2048));
+    stream_socket_shutdown($conn, STREAM_SHUT_WR);
+
+    /* Keep alive until client is done reading. */
+    fread($conn, 1);
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $source = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+    $tmp = tmpfile();
+
+    stream_copy_to_stream($source, $tmp);
+
+    fseek($tmp, 0, SEEK_SET);
+    var_dump(stream_get_contents($tmp));
+
+    fclose($tmp);
+    fclose($source);
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECTF--
+string(2048) "aaaaa%saaa"
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_socket_to_socket.phpt b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_socket.phpt
new file mode 100644
index 00000000000..d462d511709
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_socket.phpt
@@ -0,0 +1,69 @@
+--TEST--
+stream_copy_to_stream() socket to socket (splice both directions)
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$sourceCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    /* Send address again so the client can read it via phpt_wait(). */
+    phpt_notify(message: stream_socket_get_name($server, false));
+
+    $conn = stream_socket_accept($server);
+    $data = str_repeat('test data ', 1000);
+    fwrite($conn, $data);
+    stream_socket_shutdown($conn, STREAM_SHUT_WR);
+
+    /* Keep alive until client is done reading. */
+    fread($conn, 1);
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$destCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server);
+    $result = stream_get_contents($conn);
+
+    phpt_notify(message: strlen($result));
+    phpt_notify(message: $result === str_repeat('test data ', 1000) ? "match" : "mismatch");
+
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $sourceAddr = trim(phpt_wait("source"));
+    $source = stream_socket_client("tcp://$sourceAddr", $errno, $errstr, 10);
+    $dest = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+
+    $copied = stream_copy_to_stream($source, $dest);
+    var_dump($copied);
+
+    stream_socket_shutdown($dest, STREAM_SHUT_WR);
+    fclose($source);
+
+    var_dump((int) trim(phpt_wait("dest")));
+    var_dump(trim(phpt_wait("dest")) === "match");
+
+    fclose($dest);
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, [
+    'source' => $sourceCode,
+    'dest' => $destCode,
+]);
+?>
+--EXPECT--
+int(10000)
+int(10000)
+bool(true)
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_socket_to_stdout.phpt b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_stdout.phpt
new file mode 100644
index 00000000000..f378207f4ef
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_socket_to_stdout.phpt
@@ -0,0 +1,32 @@
+--TEST--
+stream_copy_to_stream() with socket as $source and STDOUT as $dest
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+
+$serverCode = <<<'CODE'
+    $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server, 5);
+    fwrite($conn, "data to stdout\n");
+    fclose($conn);
+    fclose($server);
+CODE;
+
+$clientCode = <<<'CODE'
+    $fd = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 10);
+
+    stream_copy_to_stream($fd, STDOUT);
+
+    fclose($fd);
+CODE;
+
+include sprintf("%s/../../../openssl/tests/ServerClientTestCase.inc", __DIR__);
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+data to stdout
diff --git a/ext/standard/tests/streams/stream_copy_to_stream_temp_source_partially_read.phpt b/ext/standard/tests/streams/stream_copy_to_stream_temp_source_partially_read.phpt
new file mode 100644
index 00000000000..7c4f74f9f87
--- /dev/null
+++ b/ext/standard/tests/streams/stream_copy_to_stream_temp_source_partially_read.phpt
@@ -0,0 +1,27 @@
+--TEST--
+stream_copy_to_stream() with a partially read file-backed php://temp source
+--FILE--
+<?php
+
+/* Spill php://temp to its tmpfile backing so the copy source delegates to an
+ * inner stdio stream whose buffer state the fd-level fast path cannot see. */
+$src = fopen('php://temp/maxmemory:16', 'r+');
+fwrite($src, str_repeat("A", 2000) . str_repeat("B", 2000));
+rewind($src);
+/* Read-ahead on the inner stream moves its fd offset past the position. */
+var_dump(strlen(fread($src, 2000)));
+
+$dst = tmpfile();
+$copied = stream_copy_to_stream($src, $dst);
+var_dump($copied);
+
+rewind($dst);
+var_dump(stream_get_contents($dst) === str_repeat("B", 2000));
+
+fclose($src);
+fclose($dst);
+?>
+--EXPECT--
+int(2000)
+int(2000)
+bool(true)
diff --git a/ext/zend_test/test.c b/ext/zend_test/test.c
index d1875c4946d..e8058936b6d 100644
--- a/ext/zend_test/test.c
+++ b/ext/zend_test/test.c
@@ -1892,7 +1892,7 @@ typedef off_t off64_t;
 PHP_ZEND_TEST_API ssize_t copy_file_range(int fd_in, off64_t *off_in, int fd_out, off64_t *off_out, size_t len, unsigned int flags)
 {
 	ssize_t (*original_copy_file_range)(int, off64_t *, int, off64_t *, size_t, unsigned int) = dlsym(RTLD_NEXT, "copy_file_range");
-	if (ZT_G(limit_copy_file_range) >= Z_L(0)) {
+	if (ZT_G(limit_copy_file_range) >= Z_L(0) && ZT_G(limit_copy_file_range) < len) {
 		len = ZT_G(limit_copy_file_range);
 	}
 	return original_copy_file_range(fd_in, off_in, fd_out, off_out, len, flags);
diff --git a/main/io/php_io.c b/main/io/php_io.c
new file mode 100644
index 00000000000..bd45ec88879
--- /dev/null
+++ b/main/io/php_io.c
@@ -0,0 +1,185 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: Jakub Zelenka <bukka@php.net>                               |
+   +----------------------------------------------------------------------+
+*/
+
+#include "php.h"
+#include "php_io.h"
+#include "php_io_internal.h"
+
+#include <errno.h>
+
+#ifdef PHP_WIN32
+#include <io.h>
+#include <winsock2.h>
+#else
+#include <unistd.h>
+#include <poll.h>
+#endif
+
+static php_io php_io_instance = {
+	.copy = PHP_IO_PLATFORM_COPY,
+	.platform_name = PHP_IO_PLATFORM_NAME,
+};
+
+PHPAPI php_io *php_io_get(void)
+{
+	return &php_io_instance;
+}
+
+PHPAPI zend_result php_io_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	return php_io_get()->copy(src, dest, maxlen, copied);
+}
+
+zend_result php_io_generic_copy_fallback(int src_fd, int dest_fd, size_t maxlen, size_t *copied)
+{
+	char buf[PHP_IO_COPY_BUFSIZE];
+	size_t total_copied = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? SIZE_MAX : maxlen;
+	zend_result result = SUCCESS;
+
+	while (remaining > 0) {
+		size_t to_read = (remaining < sizeof(buf)) ? remaining : sizeof(buf);
+		ssize_t bytes_read;
+		do {
+			bytes_read = read(src_fd, buf, to_read);
+		} while (bytes_read < 0 && errno == EINTR);
+
+		if (bytes_read < 0) {
+			result = FAILURE;
+			break;
+		} else if (bytes_read == 0) {
+			break;
+		}
+
+		char *writeptr = buf;
+		size_t to_write = (size_t) bytes_read;
+
+		while (to_write > 0) {
+			ssize_t bytes_written;
+			do {
+				bytes_written = write(dest_fd, writeptr, to_write);
+			} while (bytes_written < 0 && errno == EINTR);
+			if (bytes_written <= 0) {
+				result = FAILURE;
+				break;
+			}
+			total_copied += bytes_written;
+			writeptr += bytes_written;
+			to_write -= bytes_written;
+		}
+		if (result == FAILURE) {
+			break;
+		}
+
+		if (maxlen != PHP_IO_COPY_ALL) {
+			remaining -= bytes_read;
+		}
+	}
+
+	*copied = total_copied;
+	return result;
+}
+
+/* For a blocking socket source, wait until data is available (or the configured
+ * timeout elapses) before reading. Mirrors the per-platform wait_for_data
+ * helpers so the generic copy path honours stream timeouts on systems without a
+ * kernel offload (e.g. Haiku). Returns >0 ready, 0 timeout, <0 error. */
+#ifndef PHP_WIN32
+static int php_io_generic_wait_for_data(php_io_fd *fd)
+{
+	if (fd->fd_type != PHP_IO_FD_SOCKET || !fd->is_blocked) {
+		return 1;
+	}
+
+	int timeout_ms = (fd->timeout.tv_sec == -1)
+		? -1
+		: (int) (fd->timeout.tv_sec * 1000 + fd->timeout.tv_usec / 1000);
+
+	struct pollfd pfd;
+	pfd.fd = fd->fd;
+	pfd.events = POLLIN;
+
+	int ret;
+	do {
+		ret = poll(&pfd, 1, timeout_ms);
+	} while (ret == -1 && errno == EINTR);
+
+	return ret;
+}
+#else
+static int php_io_generic_wait_for_data(php_io_fd *fd)
+{
+	(void) fd;
+	return 1;
+}
+#endif
+
+zend_result php_io_generic_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	char buf[PHP_IO_COPY_BUFSIZE];
+	size_t total_copied = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? SIZE_MAX : maxlen;
+	zend_result result = SUCCESS;
+
+	while (remaining > 0) {
+		int ready = php_io_generic_wait_for_data(src);
+		if (ready == 0) {
+			/* timeout */
+			break;
+		} else if (ready < 0) {
+			result = FAILURE;
+			break;
+		}
+
+		size_t to_read = (remaining < sizeof(buf)) ? remaining : sizeof(buf);
+		ssize_t bytes_read;
+		do {
+			bytes_read = read(src->fd, buf, to_read);
+		} while (bytes_read < 0 && errno == EINTR);
+
+		if (bytes_read < 0) {
+			result = FAILURE;
+			break;
+		} else if (bytes_read == 0) {
+			break;
+		}
+
+		char *writeptr = buf;
+		size_t to_write = (size_t) bytes_read;
+
+		while (to_write > 0) {
+			ssize_t bytes_written;
+			do {
+				bytes_written = write(dest->fd, writeptr, to_write);
+			} while (bytes_written < 0 && errno == EINTR);
+			if (bytes_written <= 0) {
+				result = FAILURE;
+				break;
+			}
+			total_copied += bytes_written;
+			writeptr += bytes_written;
+			to_write -= bytes_written;
+		}
+		if (result == FAILURE) {
+			break;
+		}
+
+		if (maxlen != PHP_IO_COPY_ALL) {
+			remaining -= bytes_read;
+		}
+	}
+
+	*copied = total_copied;
+	return result;
+}
diff --git a/main/io/php_io_copy_freebsd.c b/main/io/php_io_copy_freebsd.c
new file mode 100644
index 00000000000..41f5bc3e068
--- /dev/null
+++ b/main/io/php_io_copy_freebsd.c
@@ -0,0 +1,98 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: David Carlier <devnexen@gmail.com>                          |
+   +----------------------------------------------------------------------+
+*/
+
+#if defined(__FreeBSD__) || defined(__DragonFly__)
+
+#include "php_io_internal.h"
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/uio.h>
+#include <unistd.h>
+#include <errno.h>
+
+/* Hint the kernel to read ahead a few pages from the source file so the disk
+ * I/O overlaps with the network send. SF_FLAGS() is FreeBSD-specific and may be
+ * absent on DragonFly, in which case we pass plain 0 (no readahead hint). */
+#ifdef SF_FLAGS
+# define PHP_IO_FREEBSD_SF_FLAGS SF_FLAGS(16, 0)
+#else
+# define PHP_IO_FREEBSD_SF_FLAGS 0
+#endif
+
+static zend_result php_io_freebsd_sendfile(int src_fd, int dest_fd, size_t maxlen, size_t *copied)
+{
+	off_t start_offset = lseek(src_fd, 0, SEEK_CUR);
+	if (start_offset == (off_t) -1) {
+		return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+	}
+
+	off_t total_sent = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? 0 : maxlen;
+	zend_result result = SUCCESS;
+
+	while (maxlen == PHP_IO_COPY_ALL || remaining > 0) {
+		off_t sent_in_this_call = 0;
+		int sendfile_result = sendfile(src_fd, dest_fd, start_offset + total_sent, remaining, NULL,
+				&sent_in_this_call, PHP_IO_FREEBSD_SF_FLAGS);
+
+		if (sent_in_this_call > 0) {
+			total_sent += sent_in_this_call;
+			if (maxlen != PHP_IO_COPY_ALL) {
+				remaining -= (size_t) sent_in_this_call;
+			}
+		}
+
+		if (sendfile_result == 0) {
+			if (sent_in_this_call == 0 || remaining == 0) {
+				break;
+			}
+		} else {
+			if (errno == EINTR) {
+				continue;
+			}
+			if (errno == EAGAIN) {
+				if (sent_in_this_call > 0) {
+					continue;
+				}
+				break;
+			}
+			if (total_sent == 0) {
+				return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+			}
+			result = FAILURE;
+			break;
+		}
+	}
+
+	if (total_sent > 0) {
+		/* best effort: keep reporting the delivered bytes even if this fails */
+		lseek(src_fd, start_offset + total_sent, SEEK_SET);
+	}
+
+	*copied = (size_t) total_sent;
+	return result;
+}
+
+zend_result php_io_freebsd_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	/* unlike linux, sendfile on freebsd works only under those conditions */
+	if (src->fd_type == PHP_IO_FD_FILE && dest->fd_type == PHP_IO_FD_SOCKET) {
+		return php_io_freebsd_sendfile(src->fd, dest->fd, maxlen, copied);
+	}
+
+	/* php_io_generic_copy honours the stream timeout for socket sources */
+	return php_io_generic_copy(src, dest, maxlen, copied);
+}
+
+#endif
diff --git a/main/io/php_io_copy_linux.c b/main/io/php_io_copy_linux.c
new file mode 100644
index 00000000000..9a1810349d3
--- /dev/null
+++ b/main/io/php_io_copy_linux.c
@@ -0,0 +1,354 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: Jakub Zelenka <bukka@php.net>                               |
+   +----------------------------------------------------------------------+
+*/
+
+#ifdef __linux__
+
+#include "php_io_internal.h"
+#include <unistd.h>
+#include <errno.h>
+#include <sys/syscall.h>
+
+#if !defined(HAVE_COPY_FILE_RANGE) && defined(__NR_copy_file_range)
+#define HAVE_COPY_FILE_RANGE 1
+static inline ssize_t copy_file_range(
+		int fd_in, off_t *off_in, int fd_out, off_t *off_out, size_t len, unsigned int flags)
+{
+	return syscall(__NR_copy_file_range, fd_in, off_in, fd_out, off_out, len, flags);
+}
+#endif
+
+#ifdef HAVE_SENDFILE
+#include <sys/sendfile.h>
+#endif
+
+#ifdef HAVE_SPLICE
+#include <fcntl.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#endif
+
+static inline int php_io_linux_wait_for_data(php_io_fd *fd)
+{
+	if (fd->fd_type != PHP_IO_FD_SOCKET || !fd->is_blocked) {
+		return 1;
+	}
+
+	struct timeval *ptimeout = (fd->timeout.tv_sec == -1) ? NULL : &fd->timeout;
+	int timeout_ms;
+
+	if (ptimeout == NULL) {
+		timeout_ms = -1;
+	} else {
+		timeout_ms = ptimeout->tv_sec * 1000 + ptimeout->tv_usec / 1000;
+	}
+
+	struct pollfd pfd;
+	pfd.fd = fd->fd;
+	pfd.events = POLLIN;
+
+	int ret;
+	do {
+		ret = poll(&pfd, 1, timeout_ms);
+	} while (ret == -1 && errno == EINTR);
+
+	return ret;
+}
+
+static zend_result php_io_linux_copy_file_to_file(int src_fd, int dest_fd, size_t maxlen, size_t *copied)
+{
+#ifdef HAVE_COPY_FILE_RANGE
+	size_t total_copied = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? SIZE_MAX : maxlen;
+
+	while (remaining > 0) {
+		size_t to_copy = (remaining < SSIZE_MAX) ? remaining : SSIZE_MAX;
+		ssize_t result = copy_file_range(src_fd, NULL, dest_fd, NULL, to_copy, 0);
+
+		if (result > 0) {
+			total_copied += result;
+			if (maxlen != PHP_IO_COPY_ALL) {
+				remaining -= result;
+			}
+		} else if (result == 0) {
+			break;
+		} else {
+			switch (errno) {
+				case EINVAL:
+				case EXDEV:
+				case ENOSYS:
+				case EIO:
+					if (total_copied == 0) {
+						return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+					}
+					break;
+				default:
+					*copied = total_copied;
+					return FAILURE;
+			}
+			break;
+		}
+	}
+
+	if (total_copied > 0) {
+		*copied = total_copied;
+		return SUCCESS;
+	}
+#endif
+
+	return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+}
+
+static zend_result php_io_linux_sendfile(int src_fd, int dest_fd, size_t maxlen, size_t *copied)
+{
+#ifdef HAVE_SENDFILE
+	size_t total_copied = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? SIZE_MAX : maxlen;
+
+	while (remaining > 0) {
+		size_t to_send = (remaining < SSIZE_MAX) ? remaining : SSIZE_MAX;
+		ssize_t result = sendfile(dest_fd, src_fd, NULL, to_send);
+
+		if (result > 0) {
+			total_copied += result;
+			if (maxlen != PHP_IO_COPY_ALL) {
+				remaining -= result;
+			}
+		} else if (result == 0) {
+			break;
+		} else {
+			switch (errno) {
+				case EINTR:
+					continue;
+				case EINVAL:
+				case ENOSYS:
+					if (total_copied == 0) {
+						return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+					}
+					break;
+				case EAGAIN:
+					break;
+				default:
+					*copied = total_copied;
+					return FAILURE;
+			}
+			break;
+		}
+	}
+
+	if (total_copied > 0) {
+		*copied = total_copied;
+		return SUCCESS;
+	}
+#endif
+
+	return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+}
+
+#ifdef HAVE_SPLICE
+/* Enlarge the intermediate pipe so socket transfers move more data per splice
+ * round-trip. Capped by /proc/sys/fs/pipe-max-size (1 MiB by default); a failed
+ * fcntl() simply leaves the kernel default (64 KiB) in place. */
+#define PHP_IO_PIPE_SIZE (1 << 20)
+
+/* Kernel MAX_RW_COUNT; a larger len fails the pos + len overflow check with
+ * EINVAL once a file destination sits at a non-zero offset. */
+#define PHP_IO_SPLICE_MAX ((size_t) 0x7ffff000)
+
+/* SPLICE_F_MORE corks the socket the same way MSG_MORE does, letting the kernel
+ * coalesce splices into full segments. The final partial segment is not flushed
+ * by the kernel until the cork timer (<= 200 ms) expires or the socket is next
+ * written/closed, so the copy loops below clear the cork once they are done. */
+static inline unsigned int php_io_linux_out_flags(const php_io_fd *dest)
+{
+	return (dest->fd_type == PHP_IO_FD_SOCKET) ? SPLICE_F_MORE : 0;
+}
+
+/* Clearing TCP_CORK pushes out any segment still held by SPLICE_F_MORE;
+ * on non-TCP sockets the setsockopt() harmlessly fails. */
+static inline void php_io_linux_socket_uncork(const php_io_fd *dest, size_t total_copied)
+{
+	if (dest->fd_type == PHP_IO_FD_SOCKET && total_copied > 0) {
+		int off = 0;
+		setsockopt(dest->fd, IPPROTO_TCP, TCP_CORK, &off, sizeof(off));
+	}
+}
+
+static zend_result php_io_linux_splice_from_pipe(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	int dest_fd = dest->fd;
+	unsigned int out_flags = php_io_linux_out_flags(dest);
+	size_t total_copied = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? SIZE_MAX : maxlen;
+	zend_result result = SUCCESS;
+
+	while (remaining > 0) {
+		int ready = php_io_linux_wait_for_data(src);
+		if (ready == 0) {
+			break;
+		} else if (ready < 0) {
+			result = FAILURE;
+			break;
+		}
+
+		size_t to_copy = (remaining < PHP_IO_SPLICE_MAX) ? remaining : PHP_IO_SPLICE_MAX;
+		ssize_t spliced = splice(src->fd, NULL, dest_fd, NULL, to_copy, out_flags);
+
+		if (spliced > 0) {
+			total_copied += spliced;
+			if (maxlen != PHP_IO_COPY_ALL) {
+				remaining -= spliced;
+			}
+		} else if (spliced == 0) {
+			break;
+		} else {
+			if (total_copied == 0) {
+				return php_io_generic_copy_fallback(src->fd, dest_fd, maxlen, copied);
+			}
+			result = FAILURE;
+			break;
+		}
+	}
+
+	php_io_linux_socket_uncork(dest, total_copied);
+	*copied = total_copied;
+	return result;
+}
+
+static zend_result php_io_linux_splice_via_pipe(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	int dest_fd = dest->fd;
+	unsigned int out_flags = php_io_linux_out_flags(dest);
+	int pipefd[2];
+	if (pipe(pipefd) == -1) {
+		return php_io_generic_copy_fallback(src->fd, dest_fd, maxlen, copied);
+	}
+
+#ifdef F_SETPIPE_SZ
+	fcntl(pipefd[1], F_SETPIPE_SZ, PHP_IO_PIPE_SIZE);
+#endif
+
+	size_t total_copied = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? SIZE_MAX : maxlen;
+	zend_result result = SUCCESS;
+
+	while (remaining > 0) {
+		int ready = php_io_linux_wait_for_data(src);
+		if (ready == 0) {
+			/* timeout */
+			break;
+		} else if (ready < 0) {
+			result = FAILURE;
+			break;
+		}
+
+		size_t to_copy = (remaining < SSIZE_MAX) ? remaining : SSIZE_MAX;
+
+		ssize_t in_pipe = splice(src->fd, NULL, pipefd[1], NULL, to_copy, 0);
+		if (in_pipe < 0) {
+			if (total_copied == 0) {
+				close(pipefd[0]);
+				close(pipefd[1]);
+				return php_io_generic_copy_fallback(src->fd, dest_fd, maxlen, copied);
+			}
+			result = FAILURE;
+			break;
+		}
+		if (in_pipe == 0) {
+			break;
+		}
+
+		size_t pipe_remaining = in_pipe;
+		while (pipe_remaining > 0) {
+			ssize_t out = splice(pipefd[0], NULL, dest_fd, NULL, pipe_remaining, out_flags);
+			if (out <= 0) {
+				/* the dest refused the splice; salvage what already sits in
+				 * the pipe with a plain read/write loop before failing */
+				char drain_buf[1024];
+				while (pipe_remaining > 0) {
+					size_t to_drain = (pipe_remaining < sizeof(drain_buf))
+							? pipe_remaining : sizeof(drain_buf);
+					ssize_t drained;
+					do {
+						drained = read(pipefd[0], drain_buf, to_drain);
+					} while (drained < 0 && errno == EINTR);
+					if (drained <= 0) {
+						break;
+					}
+					ssize_t drain_written = 0;
+					while (drain_written < drained) {
+						ssize_t written;
+						do {
+							written = write(dest_fd, drain_buf + drain_written, drained - drain_written);
+						} while (written < 0 && errno == EINTR);
+						if (written <= 0) {
+							total_copied += drain_written;
+							result = FAILURE;
+							goto out;
+						}
+						drain_written += written;
+					}
+					pipe_remaining -= drain_written;
+					total_copied += drain_written;
+				}
+				result = FAILURE;
+				goto out;
+			}
+			pipe_remaining -= out;
+			total_copied += out;
+		}
+
+		if (maxlen != PHP_IO_COPY_ALL) {
+			remaining -= in_pipe;
+		}
+	}
+
+out:
+	close(pipefd[0]);
+	close(pipefd[1]);
+	php_io_linux_socket_uncork(dest, total_copied);
+	*copied = total_copied;
+	return result;
+}
+#endif /* HAVE_SPLICE */
+
+zend_result php_io_linux_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	if (src->fd_type == PHP_IO_FD_FILE && dest->fd_type == PHP_IO_FD_FILE) {
+		return php_io_linux_copy_file_to_file(src->fd, dest->fd, maxlen, copied);
+	}
+
+	if (src->fd_type == PHP_IO_FD_FILE && dest->fd_type == PHP_IO_FD_SOCKET) {
+		return php_io_linux_sendfile(src->fd, dest->fd, maxlen, copied);
+	}
+
+	if (src->fd_type == PHP_IO_FD_FILE && dest->fd_type == PHP_IO_FD_PIPE) {
+		return php_io_linux_sendfile(src->fd, dest->fd, maxlen, copied);
+	}
+
+#ifdef HAVE_SPLICE
+	if (src->fd_type == PHP_IO_FD_PIPE) {
+		return php_io_linux_splice_from_pipe(src, dest, maxlen, copied);
+	}
+
+	if (src->fd_type == PHP_IO_FD_SOCKET) {
+		return php_io_linux_splice_via_pipe(src, dest, maxlen, copied);
+	}
+#endif
+
+	/* php_io_generic_copy honours the stream timeout for socket sources */
+	return php_io_generic_copy(src, dest, maxlen, copied);
+}
+
+#endif /* __linux__ */
diff --git a/main/io/php_io_copy_macos.c b/main/io/php_io_copy_macos.c
new file mode 100644
index 00000000000..0ad73c87a39
--- /dev/null
+++ b/main/io/php_io_copy_macos.c
@@ -0,0 +1,100 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: David Carlier <devnexen@gmail.com>                          |
+   +----------------------------------------------------------------------+
+*/
+
+#if defined(__APPLE__)
+
+#include "php_io_internal.h"
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/uio.h>
+#include <unistd.h>
+#include <errno.h>
+
+static zend_result php_io_macos_sendfile(int src_fd, int dest_fd, size_t maxlen, size_t *copied)
+{
+#ifdef HAVE_SENDFILE
+	/* macOS sendfile() takes an explicit offset and does not advance the
+	 * source descriptor, so remember the starting position and restore it
+	 * afterwards to keep the streams-layer contract. */
+	off_t start_offset = lseek(src_fd, 0, SEEK_CUR);
+	if (start_offset == (off_t) -1) {
+		return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+	}
+
+	off_t total_sent = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? 0 : maxlen;
+	zend_result result = SUCCESS;
+
+	while (maxlen == PHP_IO_COPY_ALL || remaining > 0) {
+		/* len is in/out: on input the number of bytes to send (0 means
+		 * until end of file), on output the number of bytes actually sent
+		 * (populated even when sendfile() reports EINTR/EAGAIN). */
+		off_t len = (maxlen == PHP_IO_COPY_ALL) ? 0 : (off_t) remaining;
+		int sendfile_result = sendfile(src_fd, dest_fd, start_offset + total_sent, &len, NULL, 0);
+
+		if (len > 0) {
+			total_sent += len;
+			if (maxlen != PHP_IO_COPY_ALL) {
+				remaining -= (size_t) len;
+			}
+		}
+
+		if (sendfile_result == 0) {
+			/* Reached EOF or sent the whole requested range. */
+			if (len == 0 || maxlen == PHP_IO_COPY_ALL || remaining == 0) {
+				break;
+			}
+		} else {
+			if (errno == EINTR) {
+				continue;
+			}
+			if (errno == EAGAIN) {
+				if (len > 0) {
+					continue;
+				}
+				break;
+			}
+			if (total_sent == 0) {
+				return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+			}
+			result = FAILURE;
+			break;
+		}
+	}
+
+	if (total_sent > 0) {
+		/* best effort: keep reporting the delivered bytes even if this fails */
+		lseek(src_fd, start_offset + total_sent, SEEK_SET);
+	}
+
+	*copied = (size_t) total_sent;
+	return result;
+#else
+	return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+#endif
+}
+
+zend_result php_io_macos_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	/* Like FreeBSD, macOS sendfile() works only with a regular file source
+	 * and a socket destination. */
+	if (src->fd_type == PHP_IO_FD_FILE && dest->fd_type == PHP_IO_FD_SOCKET) {
+		return php_io_macos_sendfile(src->fd, dest->fd, maxlen, copied);
+	}
+
+	/* php_io_generic_copy honours the stream timeout for socket sources */
+	return php_io_generic_copy(src, dest, maxlen, copied);
+}
+
+#endif
diff --git a/main/io/php_io_copy_solaris.c b/main/io/php_io_copy_solaris.c
new file mode 100644
index 00000000000..6812bbe3081
--- /dev/null
+++ b/main/io/php_io_copy_solaris.c
@@ -0,0 +1,104 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: David Carlier <devnexen@gmail.com>                          |
+   +----------------------------------------------------------------------+
+   */
+
+#if defined(__sun) && defined(__SVR4)
+
+#include "php_io_internal.h"
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <sys/sendfile.h>
+#include <unistd.h>
+#include <errno.h>
+
+static zend_result php_io_solaris_sendfile(int src_fd, int dest_fd, size_t maxlen, size_t *copied)
+{
+#ifdef HAVE_SENDFILE
+	/* Solaris/illumos sendfile() takes an explicit offset and does not
+	 * advance the source descriptor, so remember the starting position and
+	 * restore it afterwards to keep the streams-layer contract. */
+	off_t start_offset = lseek(src_fd, 0, SEEK_CUR);
+	if (start_offset == (off_t) -1) {
+		return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+	}
+
+	/* Unlike Linux, Solaris sendfile() returns -1/EINVAL (rather than 0) when
+	 * called with an offset at or past EOF, so a trailing call issued after the
+	 * source has been fully consumed would spuriously fail an otherwise complete
+	 * copy. Bound the loop to the bytes actually available in the source file so
+	 * we stop exactly at EOF and never call sendfile() past it. */
+	struct stat st;
+	if (fstat(src_fd, &st) != 0 || !S_ISREG(st.st_mode)) {
+		return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+	}
+
+	size_t available = (st.st_size > start_offset) ? (size_t) (st.st_size - start_offset) : 0;
+	size_t target = (maxlen == PHP_IO_COPY_ALL || maxlen > available) ? available : maxlen;
+
+	off_t offset = start_offset;
+	size_t total_copied = 0;
+	zend_result result = SUCCESS;
+
+	while (total_copied < target) {
+		size_t remaining = target - total_copied;
+		size_t to_send = (remaining < SSIZE_MAX) ? remaining : SSIZE_MAX;
+		/* offset is updated in place by sendfile() */
+		ssize_t sent = sendfile(dest_fd, src_fd, &offset, to_send);
+
+		if (sent > 0) {
+			total_copied += (size_t) sent;
+		} else if (sent == 0) {
+			/* Source shrank under us; stop with what we have. */
+			break;
+		} else {
+			if (errno == EINTR) {
+				continue;
+			}
+			if (errno == EAGAIN) {
+				break;
+			}
+			if (total_copied == 0) {
+				return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+			}
+			result = FAILURE;
+			break;
+		}
+	}
+
+	if (total_copied > 0) {
+		/* best effort: keep reporting the delivered bytes even if this fails */
+		lseek(src_fd, start_offset + (off_t) total_copied, SEEK_SET);
+	}
+
+	*copied = total_copied;
+	return result;
+#else
+	return php_io_generic_copy_fallback(src_fd, dest_fd, maxlen, copied);
+#endif
+}
+
+zend_result php_io_solaris_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	/* Unlike FreeBSD, Solaris/illumos sendfile() accepts both a socket and a
+	 * regular file as the output descriptor, so file->socket and file->file
+	 * can both be offloaded. */
+	if (src->fd_type == PHP_IO_FD_FILE &&
+			(dest->fd_type == PHP_IO_FD_SOCKET || dest->fd_type == PHP_IO_FD_FILE)) {
+		return php_io_solaris_sendfile(src->fd, dest->fd, maxlen, copied);
+	}
+
+	/* php_io_generic_copy honours the stream timeout for socket sources */
+	return php_io_generic_copy(src, dest, maxlen, copied);
+}
+
+#endif
diff --git a/main/io/php_io_copy_windows.c b/main/io/php_io_copy_windows.c
new file mode 100644
index 00000000000..de75d252998
--- /dev/null
+++ b/main/io/php_io_copy_windows.c
@@ -0,0 +1,189 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: Jakub Zelenka <bukka@php.net>                               |
+   +----------------------------------------------------------------------+
+*/
+
+#include "php_io_internal.h"
+
+#ifdef PHP_WIN32
+
+#include <io.h>
+#include <winsock2.h>
+#include <mswsock.h>
+
+static inline ssize_t php_io_win_read(php_io_fd *fd, char *buf, int len)
+{
+	if (fd->fd_type == PHP_IO_FD_SOCKET) {
+		int result = recv(fd->socket, buf, len, 0);
+		return (result == SOCKET_ERROR) ? -1 : (ssize_t) result;
+	}
+	return (ssize_t) _read(fd->fd, buf, len);
+}
+
+static inline ssize_t php_io_win_write(php_io_fd *fd, const char *buf, int len)
+{
+	if (fd->fd_type == PHP_IO_FD_SOCKET) {
+		int result = send(fd->socket, buf, len, 0);
+		return (result == SOCKET_ERROR) ? -1 : (ssize_t) result;
+	}
+	return (ssize_t) _write(fd->fd, buf, len);
+}
+
+static inline int php_io_win_wait_for_data(php_io_fd *fd)
+{
+	if (fd->fd_type != PHP_IO_FD_SOCKET || !fd->is_blocked) {
+		return 1;
+	}
+
+	int timeout_ms;
+	if (fd->timeout.tv_sec == -1) {
+		timeout_ms = -1;
+	} else {
+		timeout_ms = fd->timeout.tv_sec * 1000 + fd->timeout.tv_usec / 1000;
+	}
+
+	WSAPOLLFD pfd;
+	pfd.fd = fd->socket;
+	pfd.events = POLLIN;
+
+	int ret;
+	do {
+		ret = WSAPoll(&pfd, 1, timeout_ms);
+	} while (ret == SOCKET_ERROR && WSAGetLastError() == WSAEINTR);
+
+	return (ret == SOCKET_ERROR) ? -1 : ret;
+}
+
+static zend_result php_io_win_copy_loop(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	char buf[PHP_IO_COPY_BUFSIZE];
+	size_t total_copied = 0;
+	size_t remaining = (maxlen == PHP_IO_COPY_ALL) ? SIZE_MAX : maxlen;
+	zend_result result = SUCCESS;
+
+	while (remaining > 0) {
+		int ready = php_io_win_wait_for_data(src);
+		if (ready == 0) {
+			break;
+		} else if (ready < 0) {
+			result = FAILURE;
+			break;
+		}
+
+		int to_read = (remaining < sizeof(buf)) ? (int) remaining : (int) sizeof(buf);
+		ssize_t bytes_read = php_io_win_read(src, buf, to_read);
+
+		if (bytes_read < 0) {
+			result = FAILURE;
+			break;
+		} else if (bytes_read == 0) {
+			break;
+		}
+
+		const char *writeptr = buf;
+		size_t to_write = (size_t) bytes_read;
+
+		while (to_write > 0) {
+			ssize_t bytes_written = php_io_win_write(dest, writeptr, (int) to_write);
+			if (bytes_written <= 0) {
+				result = FAILURE;
+				break;
+			}
+			total_copied += bytes_written;
+			writeptr += bytes_written;
+			to_write -= bytes_written;
+		}
+		if (result == FAILURE) {
+			break;
+		}
+
+		if (maxlen != PHP_IO_COPY_ALL) {
+			remaining -= bytes_read;
+		}
+	}
+
+	*copied = total_copied;
+	return result;
+}
+
+/* Documented TransmitFile per-call max; larger requests get WSAEINVAL, so they
+ * fall back to the read/write loop. */
+#define PHP_IO_WIN_TRANSMIT_MAX ((size_t) 2147483646)
+
+/* Negative results from php_io_win_transmit_file(). */
+#define PHP_IO_WIN_TRANSMIT_FALLBACK ((ssize_t) -1) /* nothing sent; retry via loop */
+#define PHP_IO_WIN_TRANSMIT_ERROR    ((ssize_t) -2) /* may have sent data; do not retry */
+
+static ssize_t php_io_win_transmit_file(int src_fd, SOCKET dest_sock, size_t maxlen)
+{
+	HANDLE file_handle = (HANDLE) _get_osfhandle(src_fd);
+
+	if (file_handle == INVALID_HANDLE_VALUE ||
+			dest_sock == INVALID_SOCKET ||
+			GetFileType(file_handle) != FILE_TYPE_DISK) {
+		return PHP_IO_WIN_TRANSMIT_FALLBACK;
+	}
+
+	LARGE_INTEGER file_pos, file_size;
+	file_pos.QuadPart = 0;
+	if (!SetFilePointerEx(file_handle, file_pos, &file_pos, FILE_CURRENT)) {
+		return PHP_IO_WIN_TRANSMIT_FALLBACK;
+	}
+
+	if (!GetFileSizeEx(file_handle, &file_size)) {
+		return PHP_IO_WIN_TRANSMIT_FALLBACK;
+	}
+
+	LONGLONG available = file_size.QuadPart - file_pos.QuadPart;
+	if (available <= 0) {
+		return 0;
+	}
+
+	/* 64-bit compare avoids truncating maxlen into the DWORD arg below */
+	ULONGLONG to_send = (maxlen == PHP_IO_COPY_ALL || (ULONGLONG) maxlen > (ULONGLONG) available)
+		? (ULONGLONG) available : (ULONGLONG) maxlen;
+
+	if (to_send > PHP_IO_WIN_TRANSMIT_MAX) {
+		return PHP_IO_WIN_TRANSMIT_FALLBACK;
+	}
+
+	if (!TransmitFile(dest_sock, file_handle, (DWORD) to_send, 0, NULL, NULL, 0)) {
+		/* may have sent data, so don't resend via the loop */
+		return PHP_IO_WIN_TRANSMIT_ERROR;
+	}
+
+	LARGE_INTEGER advance;
+	advance.QuadPart = (LONGLONG) to_send;
+	SetFilePointerEx(file_handle, advance, NULL, FILE_CURRENT);
+
+	return (ssize_t) to_send;
+}
+
+zend_result php_io_windows_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied)
+{
+	if (src->fd_type != PHP_IO_FD_SOCKET && dest->fd_type == PHP_IO_FD_SOCKET) {
+		ssize_t result = php_io_win_transmit_file(src->fd, dest->socket, maxlen);
+		if (result >= 0) {
+			*copied = (size_t) result;
+			return SUCCESS;
+		}
+		if (result == PHP_IO_WIN_TRANSMIT_ERROR) {
+			*copied = 0;
+			return FAILURE;
+		}
+		/* PHP_IO_WIN_TRANSMIT_FALLBACK: nothing was sent, copy via the loop. */
+	}
+
+	return php_io_win_copy_loop(src, dest, maxlen, copied);
+}
+
+#endif /* PHP_WIN32 */
diff --git a/main/io/php_io_freebsd.h b/main/io/php_io_freebsd.h
new file mode 100644
index 00000000000..b45bdc81831
--- /dev/null
+++ b/main/io/php_io_freebsd.h
@@ -0,0 +1,27 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright (c) The PHP Group                                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: David Carlier <devnexen@gmail.com>                          |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_IO_FREEBSD_H
+#define PHP_IO_FREEBSD_H
+
+#define PHP_IO_PLATFORM_COPY php_io_freebsd_copy
+#if defined(__DragonFly__)
+#  define PHP_IO_PLATFORM_NAME "dragonfly"
+#else
+#  define PHP_IO_PLATFORM_NAME "freebsd"
+#endif
+
+zend_result php_io_freebsd_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied);
+
+#endif /* PHP_IO_FREEBSD_H */
diff --git a/main/io/php_io_generic.h b/main/io/php_io_generic.h
new file mode 100644
index 00000000000..35a908555dd
--- /dev/null
+++ b/main/io/php_io_generic.h
@@ -0,0 +1,21 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright (c) The PHP Group                                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: Jakub Zelenka <bukka@php.net>                               |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_IO_GENERIC_H
+#define PHP_IO_GENERIC_H
+
+#define PHP_IO_PLATFORM_COPY php_io_generic_copy
+#define PHP_IO_PLATFORM_NAME "generic"
+
+#endif /* PHP_IO_GENERIC_H */
diff --git a/main/io/php_io_internal.h b/main/io/php_io_internal.h
new file mode 100644
index 00000000000..f6bc8bf3152
--- /dev/null
+++ b/main/io/php_io_internal.h
@@ -0,0 +1,41 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright (c) The PHP Group                                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: Jakub Zelenka <bukka@php.net>                               |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_IO_INTERNAL_H
+#define PHP_IO_INTERNAL_H
+
+#include "php_io.h"
+
+/* Buffer size for the userspace read/write copy loops. Larger than the stream
+ * layer CHUNK_SIZE (8 KiB) to cut the number of syscalls on bulk transfers. */
+#define PHP_IO_COPY_BUFSIZE (64 * 1024)
+
+zend_result php_io_generic_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied);
+zend_result php_io_generic_copy_fallback(int src_fd, int dest_fd, size_t maxlen, size_t *copied);
+
+#ifdef __linux__
+#include "php_io_linux.h"
+#elif defined(PHP_WIN32)
+#include "php_io_windows.h"
+#elif defined(__FreeBSD__) || defined(__DragonFly__)
+#include "php_io_freebsd.h"
+#elif defined(__sun) && defined(__SVR4)
+#include "php_io_solaris.h"
+#elif defined(__APPLE__)
+#include "php_io_macos.h"
+#else
+#include "php_io_generic.h"
+#endif
+
+#endif /* PHP_IO_INTERNAL_H */
diff --git a/main/io/php_io_linux.h b/main/io/php_io_linux.h
new file mode 100644
index 00000000000..801b31c236a
--- /dev/null
+++ b/main/io/php_io_linux.h
@@ -0,0 +1,23 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: Jakub Zelenka <bukka@php.net>                               |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_IO_LINUX_H
+#define PHP_IO_LINUX_H
+
+zend_result php_io_linux_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied);
+
+#define PHP_IO_PLATFORM_COPY php_io_linux_copy
+#define PHP_IO_PLATFORM_NAME "linux"
+
+#endif /* PHP_IO_LINUX_H */
diff --git a/main/io/php_io_macos.h b/main/io/php_io_macos.h
new file mode 100644
index 00000000000..b04f270e6d5
--- /dev/null
+++ b/main/io/php_io_macos.h
@@ -0,0 +1,23 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright (c) The PHP Group                                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: David Carlier <devnexen@gmail.com>                          |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_IO_MACOS_H
+#define PHP_IO_MACOS_H
+
+#define PHP_IO_PLATFORM_COPY php_io_macos_copy
+#define PHP_IO_PLATFORM_NAME "macos"
+
+zend_result php_io_macos_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied);
+
+#endif /* PHP_IO_MACOS_H */
diff --git a/main/io/php_io_solaris.h b/main/io/php_io_solaris.h
new file mode 100644
index 00000000000..ccba6e6ddd8
--- /dev/null
+++ b/main/io/php_io_solaris.h
@@ -0,0 +1,23 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: David Carlier <devnexen@gmail.com>                          |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_IO_SOLARIS_H
+#define PHP_IO_SOLARIS_H
+
+#define PHP_IO_PLATFORM_COPY php_io_solaris_copy
+#define PHP_IO_PLATFORM_NAME "solaris"
+
+zend_result php_io_solaris_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied);
+
+#endif /* PHP_IO_SOLARIS_H */
diff --git a/main/io/php_io_windows.h b/main/io/php_io_windows.h
new file mode 100644
index 00000000000..f6bd64f11ce
--- /dev/null
+++ b/main/io/php_io_windows.h
@@ -0,0 +1,23 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: Jakub Zelenka <bukka@php.net>                               |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_IO_WINDOWS_H
+#define PHP_IO_WINDOWS_H
+
+zend_result php_io_windows_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied);
+
+#define PHP_IO_PLATFORM_COPY php_io_windows_copy
+#define PHP_IO_PLATFORM_NAME "windows"
+
+#endif /* PHP_IO_WINDOWS_H */
diff --git a/main/php_io.h b/main/php_io.h
new file mode 100644
index 00000000000..79bc245ef75
--- /dev/null
+++ b/main/php_io.h
@@ -0,0 +1,53 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright (c) The PHP Group                                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to version 3.01 of the PHP license,      |
+   | that is bundled with this package in the file LICENSE, and is        |
+   | available through the world-wide-web at the following url:           |
+   | https://www.php.net/license/3_01.txt                                 |
+   | If you did not receive a copy of the PHP license and are unable to   |
+   | obtain it through the world-wide-web, please send a note to          |
+   | license@php.net so we can mail you a copy immediately.               |
+   +----------------------------------------------------------------------+
+   | Authors: Jakub Zelenka <bukka@php.net>                               |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_IO_H
+#define PHP_IO_H
+
+#include "php.h"
+#include "php_network.h"
+
+#define PHP_IO_COPY_ALL SIZE_MAX
+
+typedef enum php_io_fd_type {
+	PHP_IO_FD_FILE = 1,
+	PHP_IO_FD_SOCKET,
+	PHP_IO_FD_PIPE,
+} php_io_fd_type;
+
+typedef struct php_io_fd {
+	union {
+		int fd;
+		php_socket_t socket;
+	};
+	php_io_fd_type fd_type;
+	struct timeval timeout;
+	unsigned is_blocked:1;
+} php_io_fd;
+
+typedef zend_result (*php_io_copy_fn)(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied);
+
+typedef struct php_io {
+	php_io_copy_fn copy;
+	const char *platform_name;
+} php_io;
+
+PHPAPI php_io *php_io_get(void);
+
+/* Copies up to maxlen bytes from src to dest; *copied is set even on FAILURE */
+PHPAPI zend_result php_io_copy(php_io_fd *src, php_io_fd *dest, size_t maxlen, size_t *copied);
+
+#endif /* PHP_IO_H */
diff --git a/main/php_streams.h b/main/php_streams.h
index 7622a7295af..be8009dba91 100644
--- a/main/php_streams.h
+++ b/main/php_streams.h
@@ -555,6 +555,8 @@ END_EXTERN_C()
 #define PHP_STREAM_AS_SOCKETD	2
 /* cast as fd/socket for select purposes */
 #define PHP_STREAM_AS_FD_FOR_SELECT 3
+/* cast as fd/socket for copy purposes */
+#define PHP_STREAM_AS_FD_FOR_COPY   4

 /* try really, really hard to make sure the cast happens (avoid using this flag if possible) */
 #define PHP_STREAM_CAST_TRY_HARD	0x80000000
diff --git a/main/streams/cast.c b/main/streams/cast.c
index f480f3cd59a..863afe4ed4b 100644
--- a/main/streams/cast.c
+++ b/main/streams/cast.c
@@ -194,7 +194,7 @@ PHPAPI zend_result _php_stream_cast(php_stream *stream, int castas, void **ret,
 	castas &= ~PHP_STREAM_CAST_MASK;

 	/* synchronize our buffer (if possible) */
-	if (ret && castas != PHP_STREAM_AS_FD_FOR_SELECT) {
+	if (ret && castas != PHP_STREAM_AS_FD_FOR_SELECT && castas != PHP_STREAM_AS_FD_FOR_COPY) {
 		php_stream_flush(stream);
 		if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0) {
 			zend_off_t dummy;
@@ -204,6 +204,16 @@ PHPAPI zend_result _php_stream_cast(php_stream *stream, int castas, void **ret,
 		}
 	}

+	if (castas == PHP_STREAM_AS_FD_FOR_COPY) {
+		if (php_stream_is_filtered(stream)) {
+			return FAILURE;
+		}
+		if (stream->ops->cast && stream->ops->cast(stream, castas, ret) == SUCCESS) {
+			return SUCCESS;
+		}
+		return FAILURE;
+	}
+
 	/* filtered streams can only be cast as stdio, and only when fopencookie is present */

 	if (castas == PHP_STREAM_AS_STDIO) {
diff --git a/main/streams/memory.c b/main/streams/memory.c
index 1cc1886e609..e76598ed0f4 100644
--- a/main/streams/memory.c
+++ b/main/streams/memory.c
@@ -471,6 +471,11 @@ static int php_stream_temp_cast(php_stream *stream, int castas, void **ret)
 	if (!ts->innerstream) {
 		return FAILURE;
 	}
+	if (castas == PHP_STREAM_AS_FD_FOR_COPY) {
+		/* the fd would come from the inner stream whose buffer state and
+		 * position the copy fast path cannot see, so use the stream fallback */
+		return FAILURE;
+	}
 	if (php_stream_is(ts->innerstream, PHP_STREAM_IS_STDIO)) {
 		return php_stream_cast(ts->innerstream, castas, ret, 0);
 	}
diff --git a/main/streams/plain_wrapper.c b/main/streams/plain_wrapper.c
index e0fda0db78a..4db810b8b5e 100644
--- a/main/streams/plain_wrapper.c
+++ b/main/streams/plain_wrapper.c
@@ -32,6 +32,7 @@
 #endif
 #include "SAPI.h"

+#include "php_io.h"
 #include "php_streams_int.h"
 #ifdef PHP_WIN32
 # include "win32/winutil.h"
@@ -699,7 +700,6 @@ static int php_stdiop_cast(php_stream *stream, int castas, void **ret)

 		case PHP_STREAM_AS_FD:
 			PHP_STDIOP_GET_FD(fd, data);
-
 			if (SOCK_ERR == fd) {
 				return FAILURE;
 			}
@@ -710,6 +710,26 @@ static int php_stdiop_cast(php_stream *stream, int castas, void **ret)
 				*(php_socket_t *)ret = fd;
 			}
 			return SUCCESS;
+
+		case PHP_STREAM_AS_FD_FOR_COPY:
+			/* stdio may read ahead, so use the buffered fallback for FILE* streams */
+			if (data->file) {
+				return FAILURE;
+			}
+			PHP_STDIOP_GET_FD(fd, data);
+			if (SOCK_ERR == fd) {
+				return FAILURE;
+			}
+			if (ret) {
+				php_io_fd *copy_fd = (php_io_fd *) ret;
+				copy_fd->fd = fd;
+				copy_fd->fd_type = data->is_pipe ? PHP_IO_FD_PIPE : PHP_IO_FD_FILE;
+				copy_fd->timeout.tv_sec = 0;
+				copy_fd->timeout.tv_usec = 0;
+				copy_fd->is_blocked = 0;
+			}
+			return SUCCESS;
+
 		default:
 			return FAILURE;
 	}
diff --git a/main/streams/streams.c b/main/streams/streams.c
index 453c61b56a2..5c1041ea3a9 100644
--- a/main/streams/streams.c
+++ b/main/streams/streams.c
@@ -22,6 +22,7 @@
 #include "php_globals.h"
 #include "php_memory_streams.h"
 #include "php_network.h"
+#include "php_io.h"
 #include "php_open_temporary_file.h"
 #include "ext/standard/file.h"
 #include "ext/standard/basic_functions.h" /* for BG(CurrentStatFile) */
@@ -1588,164 +1589,17 @@ PHPAPI zend_string *_php_stream_copy_to_mem(php_stream *src, size_t maxlen, bool
 	return result;
 }

-/* Returns SUCCESS/FAILURE and sets *len to the number of bytes moved */
-PHPAPI zend_result _php_stream_copy_to_stream_ex(php_stream *src, php_stream *dest, size_t maxlen, size_t *len STREAMS_DC)
+/* Fallback copy using stream read/write API */
+static zend_result php_stream_copy_fallback(php_stream *src, php_stream *dest, size_t maxlen, size_t *len)
 {
 	char buf[CHUNK_SIZE];
 	size_t haveread = 0;
-	size_t towrite;
-	size_t dummy;
-
-	if (!len) {
-		len = &dummy;
-	}
-
-	if (maxlen == 0) {
-		*len = 0;
-		return SUCCESS;
-	}
-
-#ifdef HAVE_COPY_FILE_RANGE
-	if (php_stream_is(src, PHP_STREAM_IS_STDIO) &&
-			php_stream_is(dest, PHP_STREAM_IS_STDIO) &&
-			src->writepos == src->readpos) {
-		/* both php_stream instances are backed by a file descriptor, are not filtered and the
-		 * read buffer is empty: we can use copy_file_range() */
-		int src_fd, dest_fd, dest_open_flags = 0;
-
-		/* copy_file_range does not work with O_APPEND */
-		if (php_stream_cast(src, PHP_STREAM_AS_FD, (void*)&src_fd, 0) == SUCCESS &&
-				php_stream_cast(dest, PHP_STREAM_AS_FD, (void*)&dest_fd, 0) == SUCCESS &&
-				/* get dest open flags to check if the stream is open in append mode */
-				php_stream_parse_fopen_modes(dest->mode, &dest_open_flags) == SUCCESS &&
-				!(dest_open_flags & O_APPEND)) {
-
-			/* clamp to INT_MAX to avoid EOVERFLOW */
-			const size_t cfr_max = MIN(maxlen, (size_t)SSIZE_MAX);
-
-			/* copy_file_range() is a Linux-specific system call which allows efficient copying
-			 * between two file descriptors, eliminating the need to transfer data from the kernel
-			 * to userspace and back. For networking file systems like NFS and Ceph, it even
-			 * eliminates copying data to the client, and local filesystems like Btrfs and XFS can
-			 * create shared extents. */
-			ssize_t result = copy_file_range(src_fd, NULL, dest_fd, NULL, cfr_max, 0);
-			if (result > 0) {
-				size_t nbytes = (size_t)result;
-				haveread += nbytes;
-
-				src->position += nbytes;
-				dest->position += nbytes;
-
-				if ((maxlen != PHP_STREAM_COPY_ALL && nbytes == maxlen) || php_stream_eof(src)) {
-					/* the whole request was satisfied or end-of-file reached - done */
-					*len = haveread;
-					return SUCCESS;
-				}
-
-				/* there may be more data; continue copying using the fallback code below */
-			} else if (result == 0) {
-				/* end of file */
-				*len = haveread;
-				return SUCCESS;
-			} else if (result < 0) {
-				switch (errno) {
-					case EINVAL:
-						/* some formal error, e.g. overlapping file ranges */
-						break;
-
-					case EXDEV:
-						/* pre Linux 5.3 error */
-						break;
-
-					case ENOSYS:
-						/* not implemented by this Linux kernel */
-						break;
-
-					case EIO:
-						/* Some filesystems will cause failures if the max length is greater than the file length
-						 * in certain circumstances and configuration. In those cases the errno is EIO and we will
-						 * fall back to other methods. We cannot use stat to determine the file length upfront because
-						 * that is prone to races and outdated caching. */
-						break;
-
-					default:
-						/* unexpected I/O error - give up, no fallback */
-						*len = haveread;
-						return FAILURE;
-				}
-
-				/* fall back to classic copying */
-			}
-		}
-	}
-#endif // HAVE_COPY_FILE_RANGE

 	if (maxlen == PHP_STREAM_COPY_ALL) {
 		maxlen = 0;
 	}

-	if (php_stream_mmap_possible(src)) {
-		char *p;
-
-		do {
-			/* We must not modify maxlen here, because otherwise the file copy fallback below can fail */
-			size_t chunk_size, must_read, mapped;
-			if (maxlen == 0) {
-				/* Unlimited read */
-				must_read = chunk_size = PHP_STREAM_MMAP_MAX;
-			} else {
-				must_read = maxlen - haveread;
-				if (must_read >= PHP_STREAM_MMAP_MAX) {
-					chunk_size = PHP_STREAM_MMAP_MAX;
-				} else {
-					/* In case the length we still have to read from the file could be smaller than the file size,
-					 * chunk_size must not get bigger the size we're trying to read. */
-					chunk_size = must_read;
-				}
-			}
-
-			p = php_stream_mmap_range(src, php_stream_tell(src), chunk_size, PHP_STREAM_MAP_MODE_SHARED_READONLY, &mapped);
-
-			if (p) {
-				ssize_t didwrite;
-
-				if (php_stream_seek(src, mapped, SEEK_CUR) != 0) {
-					php_stream_mmap_unmap(src);
-					break;
-				}
-
-				didwrite = php_stream_write(dest, p, mapped);
-				if (didwrite < 0) {
-					*len = haveread;
-					php_stream_mmap_unmap(src);
-					return FAILURE;
-				}
-
-				php_stream_mmap_unmap(src);
-
-				*len = haveread += didwrite;
-
-				/* we've got at least 1 byte to read
-				 * less than 1 is an error
-				 * AND read bytes match written */
-				if (mapped == 0 || mapped != didwrite) {
-					return FAILURE;
-				}
-				if (mapped < chunk_size) {
-					return SUCCESS;
-				}
-				/* If we're not reading as much as possible, so a bounded read */
-				if (maxlen != 0) {
-					must_read -= mapped;
-					if (must_read == 0) {
-						return SUCCESS;
-					}
-				}
-			}
-		} while (p);
-	}
-
-	while(1) {
+	while (1) {
 		size_t readchunk = sizeof(buf);
 		ssize_t didread;
 		char *writeptr;
@@ -1760,14 +1614,14 @@ PHPAPI zend_result _php_stream_copy_to_stream_ex(php_stream *src, php_stream *de
 			return didread < 0 ? FAILURE : SUCCESS;
 		}

-		towrite = didread;
+		size_t towrite = didread;
 		writeptr = buf;
 		haveread += didread;

 		while (towrite) {
 			ssize_t didwrite = php_stream_write(dest, writeptr, towrite);
 			if (didwrite <= 0) {
-				*len = haveread - (didread - towrite);
+				*len = haveread - towrite;
 				return FAILURE;
 			}

@@ -1784,6 +1638,53 @@ PHPAPI zend_result _php_stream_copy_to_stream_ex(php_stream *src, php_stream *de
 	return SUCCESS;
 }

+PHPAPI zend_result _php_stream_copy_to_stream_ex(php_stream *src, php_stream *dest, size_t maxlen, size_t *len STREAMS_DC)
+{
+	size_t dummy;
+
+	if (!len) {
+		len = &dummy;
+	}
+
+	if (maxlen == 0) {
+		*len = 0;
+		return SUCCESS;
+	}
+
+	/* Try optimized fd-level copy if both streams support it and their read buffers
+	 * 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_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 &&
+				php_stream_cast(dest, PHP_STREAM_AS_FD_FOR_COPY, (void *) &dest_copy_fd, 0) == SUCCESS) {
+
+			/* copy_file_range does not work with O_APPEND */
+			if (src_copy_fd.fd_type == PHP_IO_FD_FILE && dest_copy_fd.fd_type == PHP_IO_FD_FILE) {
+				int dest_flags = 0;
+				if (php_stream_parse_fopen_modes(dest->mode, &dest_flags) == SUCCESS
+						&& (dest_flags & O_APPEND)) {
+					goto fallback;
+				}
+			}
+
+			size_t io_maxlen = (maxlen == PHP_STREAM_COPY_ALL) ? PHP_IO_COPY_ALL : maxlen;
+			size_t copied = 0;
+			zend_result result = php_io_copy(&src_copy_fd, &dest_copy_fd, io_maxlen, &copied);
+
+			src->position += copied;
+			dest->position += copied;
+			*len = copied;
+			return result;
+		}
+	}
+
+fallback:
+	return php_stream_copy_fallback(src, dest, maxlen, len);
+}
+
 /* Returns the number of bytes moved.
  * Returns 1 when source len is 0.
  * Deprecated in favor of php_stream_copy_to_stream_ex() */
diff --git a/main/streams/xp_socket.c b/main/streams/xp_socket.c
index d18b0e90195..3eef731544d 100644
--- a/main/streams/xp_socket.c
+++ b/main/streams/xp_socket.c
@@ -15,7 +15,7 @@
 #include "php.h"
 #include "ext/standard/file.h"
 #include "php_streams.h"
-#include "php_network.h"
+#include "php_io.h"

 #if defined(PHP_WIN32) || defined(__riscos__)
 # undef AF_UNIX
@@ -523,11 +523,19 @@ static int php_sockop_cast(php_stream *stream, int castas, void **ret)
 			if (ret)
 				*(php_socket_t *)ret = sock->socket;
 			return SUCCESS;
+		case PHP_STREAM_AS_FD_FOR_COPY:
+			if (ret) {
+				php_io_fd *copy_fd = (php_io_fd *) ret;
+				copy_fd->socket = sock->socket;
+				copy_fd->fd_type = PHP_IO_FD_SOCKET;
+				copy_fd->timeout = sock->timeout;
+				copy_fd->is_blocked = sock->is_blocked;
+			}
+			return SUCCESS;
 		default:
 			return FAILURE;
 	}
 }
-/* }}} */

 /* These may look identical, but we need them this way so that
  * we can determine which type of socket we are dealing with
diff --git a/tests/unit/Makefile b/tests/unit/Makefile
index ae8eeb8ab3e..778a0332823 100644
--- a/tests/unit/Makefile
+++ b/tests/unit/Makefile
@@ -3,9 +3,11 @@ CFLAGS = -g -Wall -I../../ -I../../Zend -I../../main -I../../TSRM -I. -I..
 COMMON_LDFLAGS = ../../.libs/libphp.a -lcmocka -lpthread -lm -ldl -lresolv -lutil

 # Update paths in .github/workflows/unit-tests.yml when adding new test to make it run in PR when such file changes
-TESTS = main/test_network
+TESTS = main/test_network main/test_io_copy
 main/test_network_SRC = main/test_network.c
 main/test_network_LDFLAGS = $(COMMON_LDFLAGS) -Wl,--wrap=connect,--wrap=poll,--wrap=getsockopt,--wrap=gettimeofday
+main/test_io_copy_SRC = main/test_io_copy.c
+main/test_io_copy_LDFLAGS = $(COMMON_LDFLAGS) -Wl,--wrap=copy_file_range,--wrap=sendfile,--wrap=splice,--wrap=read,--wrap=write,--wrap=poll,--wrap=setsockopt


 # Build all tests
diff --git a/tests/unit/main/test_io_copy.c b/tests/unit/main/test_io_copy.c
new file mode 100644
index 00000000000..4de0d41227d
--- /dev/null
+++ b/tests/unit/main/test_io_copy.c
@@ -0,0 +1,293 @@
+#include "php.h"
+#include "io/php_io_internal.h"
+#include <fcntl.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <cmocka.h>
+
+/* Mocked syscalls return the value queued via will_return(); a negative value
+ * -E is translated to a -1 return with errno set to E. Must expand inside the
+ * wrapper body because cmocka keys the queued values by __func__. */
+#define MOCK_IO_RESULT(result_var) \
+	ssize_t result_var = mock_type(ssize_t); \
+	if (result_var < 0) { \
+		errno = (int) -result_var; \
+		result_var = -1; \
+	}
+
+ssize_t __wrap_copy_file_range(int fd_in, off_t *off_in, int fd_out, off_t *off_out, size_t len, unsigned int flags)
+{
+	function_called();
+	MOCK_IO_RESULT(result);
+	return result;
+}
+
+ssize_t __wrap_sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
+{
+	function_called();
+	MOCK_IO_RESULT(result);
+	return result;
+}
+
+ssize_t __wrap_splice(int fd_in, off_t *off_in, int fd_out, off_t *off_out, size_t len, unsigned int flags)
+{
+	function_called();
+	check_expected(len);
+	check_expected(flags);
+	MOCK_IO_RESULT(result);
+	return result;
+}
+
+ssize_t __wrap_read(int fd, void *buf, size_t count)
+{
+	function_called();
+	MOCK_IO_RESULT(result);
+	if (result > 0) {
+		memset(buf, 'x', result);
+	}
+	return result;
+}
+
+ssize_t __wrap_write(int fd, const void *buf, size_t count)
+{
+	function_called();
+	MOCK_IO_RESULT(result);
+	return result;
+}
+
+int __wrap_poll(struct pollfd *ufds, nfds_t nfds, int timeout)
+{
+	function_called();
+	check_expected(timeout);
+
+	int n = mock_type(int);
+	if (n > 0) {
+		ufds->revents = POLLIN;
+	} else if (n < 0) {
+		errno = -n;
+		n = -1;
+	}
+
+	return n;
+}
+
+int __wrap_setsockopt(int fd, int level, int optname, const void *optval, socklen_t optlen)
+{
+	function_called();
+	check_expected(optname);
+	return 0;
+}
+
+/* Kernel MAX_RW_COUNT, must match PHP_IO_SPLICE_MAX in php_io_copy_linux.c */
+#define TEST_SPLICE_MAX ((size_t) 0x7ffff000)
+
+static php_io_fd make_io_fd(int fd, php_io_fd_type fd_type)
+{
+	php_io_fd io_fd = {
+		.fd = fd,
+		.fd_type = fd_type,
+		.timeout = { .tv_sec = -1, .tv_usec = 0 },
+		.is_blocked = 0,
+	};
+	return io_fd;
+}
+
+/* file -> file: a hard error after partial progress must report FAILURE
+ * together with the bytes already copied */
+static void test_file_to_file_error_after_partial(void **state)
+{
+	php_io_fd src = make_io_fd(10, PHP_IO_FD_FILE);
+	php_io_fd dest = make_io_fd(11, PHP_IO_FD_FILE);
+	size_t copied = 0;
+
+	expect_function_calls(__wrap_copy_file_range, 2);
+	will_return(__wrap_copy_file_range, 4096);
+	will_return(__wrap_copy_file_range, -ENOSPC);
+
+	assert_int_equal(php_io_linux_copy(&src, &dest, PHP_IO_COPY_ALL, &copied), FAILURE);
+	assert_int_equal(copied, 4096);
+}
+
+/* file -> file: EXDEV with no progress falls back to the read/write loop */
+static void test_file_to_file_exdev_fallback(void **state)
+{
+	php_io_fd src = make_io_fd(10, PHP_IO_FD_FILE);
+	php_io_fd dest = make_io_fd(11, PHP_IO_FD_FILE);
+	size_t copied = 0;
+
+	expect_function_call(__wrap_copy_file_range);
+	will_return(__wrap_copy_file_range, -EXDEV);
+
+	expect_function_call(__wrap_read);
+	will_return(__wrap_read, 100);
+	expect_function_call(__wrap_write);
+	will_return(__wrap_write, 100);
+	expect_function_call(__wrap_read);
+	will_return(__wrap_read, 0);
+
+	assert_int_equal(php_io_linux_copy(&src, &dest, PHP_IO_COPY_ALL, &copied), SUCCESS);
+	assert_int_equal(copied, 100);
+}
+
+/* the generic fallback loop must retry interrupted read() and write() */
+static void test_generic_fallback_eintr_retry(void **state)
+{
+	size_t copied = 0;
+
+	expect_function_calls(__wrap_read, 2);
+	will_return(__wrap_read, -EINTR);
+	will_return(__wrap_read, 50);
+	expect_function_calls(__wrap_write, 2);
+	will_return(__wrap_write, -EINTR);
+	will_return(__wrap_write, 50);
+	expect_function_call(__wrap_read);
+	will_return(__wrap_read, 0);
+
+	assert_int_equal(php_io_generic_copy_fallback(10, 11, PHP_IO_COPY_ALL, &copied), SUCCESS);
+	assert_int_equal(copied, 50);
+}
+
+/* the generic fallback loop must report FAILURE with the partial count when a
+ * write fails mid-copy */
+static void test_generic_fallback_write_error_after_partial(void **state)
+{
+	size_t copied = 0;
+
+	expect_function_call(__wrap_read);
+	will_return(__wrap_read, 50);
+	expect_function_call(__wrap_write);
+	will_return(__wrap_write, 50);
+	expect_function_call(__wrap_read);
+	will_return(__wrap_read, 50);
+	expect_function_call(__wrap_write);
+	will_return(__wrap_write, -ENOSPC);
+
+	assert_int_equal(php_io_generic_copy_fallback(10, 11, PHP_IO_COPY_ALL, &copied), FAILURE);
+	assert_int_equal(copied, 50);
+}
+
+/* file -> socket: a hard sendfile error after partial progress must report
+ * FAILURE together with the bytes already sent */
+static void test_file_to_socket_sendfile_error_after_partial(void **state)
+{
+	php_io_fd src = make_io_fd(10, PHP_IO_FD_FILE);
+	php_io_fd dest = make_io_fd(11, PHP_IO_FD_SOCKET);
+	size_t copied = 0;
+
+	expect_function_calls(__wrap_sendfile, 2);
+	will_return(__wrap_sendfile, 8192);
+	will_return(__wrap_sendfile, -EPIPE);
+
+	assert_int_equal(php_io_linux_copy(&src, &dest, PHP_IO_COPY_ALL, &copied), FAILURE);
+	assert_int_equal(copied, 8192);
+}
+
+/* file -> socket: sendfile EINVAL with no progress falls back to the
+ * read/write loop */
+static void test_file_to_socket_sendfile_einval_fallback(void **state)
+{
+	php_io_fd src = make_io_fd(10, PHP_IO_FD_FILE);
+	php_io_fd dest = make_io_fd(11, PHP_IO_FD_SOCKET);
+	size_t copied = 0;
+
+	expect_function_call(__wrap_sendfile);
+	will_return(__wrap_sendfile, -EINVAL);
+
+	expect_function_call(__wrap_read);
+	will_return(__wrap_read, 10);
+	expect_function_call(__wrap_write);
+	will_return(__wrap_write, 10);
+	expect_function_call(__wrap_read);
+	will_return(__wrap_read, 0);
+
+	assert_int_equal(php_io_linux_copy(&src, &dest, PHP_IO_COPY_ALL, &copied), SUCCESS);
+	assert_int_equal(copied, 10);
+}
+
+/* socket -> file: a blocking source socket must be polled with the stream
+ * timeout and a timeout is a clean stop, not an error */
+static void test_socket_source_poll_timeout(void **state)
+{
+	php_io_fd src = make_io_fd(10, PHP_IO_FD_SOCKET);
+	php_io_fd dest = make_io_fd(11, PHP_IO_FD_FILE);
+	size_t copied = 42;
+
+	src.is_blocked = 1;
+	src.timeout.tv_sec = 2;
+	src.timeout.tv_usec = 500000;
+
+	expect_function_call(__wrap_poll);
+	expect_value(__wrap_poll, timeout, 2500);
+	will_return(__wrap_poll, 0);
+
+	assert_int_equal(php_io_linux_copy(&src, &dest, PHP_IO_COPY_ALL, &copied), SUCCESS);
+	assert_int_equal(copied, 0);
+}
+
+/* pipe -> socket: splices are corked with SPLICE_F_MORE and capped at the
+ * kernel MAX_RW_COUNT; an error after partial progress must report FAILURE
+ * with the partial count and still clear the cork */
+static void test_pipe_to_socket_splice_error_after_partial_uncorks(void **state)
+{
+	php_io_fd src = make_io_fd(10, PHP_IO_FD_PIPE);
+	php_io_fd dest = make_io_fd(11, PHP_IO_FD_SOCKET);
+	size_t copied = 0;
+
+	expect_function_calls(__wrap_splice, 2);
+	expect_value_count(__wrap_splice, len, TEST_SPLICE_MAX, 2);
+	expect_value_count(__wrap_splice, flags, SPLICE_F_MORE, 2);
+	will_return(__wrap_splice, 1000);
+	will_return(__wrap_splice, -EPIPE);
+
+	expect_function_call(__wrap_setsockopt);
+	expect_value(__wrap_setsockopt, optname, TCP_CORK);
+
+	assert_int_equal(php_io_linux_copy(&src, &dest, PHP_IO_COPY_ALL, &copied), FAILURE);
+	assert_int_equal(copied, 1000);
+}
+
+/* socket -> file: when the outbound splice fails, the data already sitting in
+ * the intermediate pipe is salvaged with a read/write loop (retrying EINTR)
+ * and the copy reports FAILURE with the salvaged count */
+static void test_socket_to_file_splice_out_error_drains_pipe(void **state)
+{
+	php_io_fd src = make_io_fd(10, PHP_IO_FD_SOCKET);
+	php_io_fd dest = make_io_fd(11, PHP_IO_FD_FILE);
+	size_t copied = 0;
+
+	expect_function_calls(__wrap_splice, 2);
+	/* socket -> pipe */
+	expect_value(__wrap_splice, len, SSIZE_MAX);
+	expect_value(__wrap_splice, flags, 0);
+	will_return(__wrap_splice, 500);
+	/* pipe -> file */
+	expect_value(__wrap_splice, len, 500);
+	expect_value(__wrap_splice, flags, 0);
+	will_return(__wrap_splice, -EINVAL);
+
+	expect_function_calls(__wrap_read, 2);
+	will_return(__wrap_read, -EINTR);
+	will_return(__wrap_read, 500);
+	expect_function_call(__wrap_write);
+	will_return(__wrap_write, 500);
+
+	assert_int_equal(php_io_linux_copy(&src, &dest, PHP_IO_COPY_ALL, &copied), FAILURE);
+	assert_int_equal(copied, 500);
+}
+
+int main(void)
+{
+	const struct CMUnitTest tests[] = {
+		cmocka_unit_test(test_file_to_file_error_after_partial),
+		cmocka_unit_test(test_file_to_file_exdev_fallback),
+		cmocka_unit_test(test_generic_fallback_eintr_retry),
+		cmocka_unit_test(test_generic_fallback_write_error_after_partial),
+		cmocka_unit_test(test_file_to_socket_sendfile_error_after_partial),
+		cmocka_unit_test(test_file_to_socket_sendfile_einval_fallback),
+		cmocka_unit_test(test_socket_source_poll_timeout),
+		cmocka_unit_test(test_pipe_to_socket_splice_error_after_partial_uncorks),
+		cmocka_unit_test(test_socket_to_file_splice_out_error_drains_pipe),
+	};
+	return cmocka_run_group_tests(tests, NULL, NULL);
+}
diff --git a/win32/build/config.w32 b/win32/build/config.w32
index 8ec6b31a11a..012c52499b6 100644
--- a/win32/build/config.w32
+++ b/win32/build/config.w32
@@ -298,6 +298,9 @@ AC_DEFINE('HAVE_STRNLEN', 1);

 AC_DEFINE('ZEND_CHECK_STACK_LIMIT', 1)

+ADD_SOURCES("main/io", "php_io.c php_io_copy_windows.c");
+ADD_FLAG("CFLAGS_BD_MAIN_IO", "/D ZEND_ENABLE_STATIC_TSRMLS_CACHE=1");
+
 ADD_SOURCES("main/poll", "poll_backend_wsapoll.c poll_core.c poll_fd_table.c poll_handle.c");
 ADD_FLAG("CFLAGS_BD_MAIN_POLL", "/D ZEND_ENABLE_STATIC_TSRMLS_CACHE=1");

@@ -312,7 +315,7 @@ ADD_SOURCES("win32", "dllmain.c readdir.c \

 ADD_FLAG("CFLAGS_BD_WIN32", "/D ZEND_ENABLE_STATIC_TSRMLS_CACHE=1");

-PHP_INSTALL_HEADERS("", "Zend/ TSRM/ main/ main/streams/ win32/");
+PHP_INSTALL_HEADERS("", "Zend/ TSRM/ main/ main/io main/streams/ win32/");
 PHP_INSTALL_HEADERS("Zend/Optimizer", "zend_call_graph.h zend_cfg.h zend_dfg.h zend_dump.h zend_func_info.h zend_inference.h zend_optimizer.h zend_ssa.h zend_worklist.h");

 STDOUT.WriteBlankLines(1);
diff --git a/win32/build/confutils.js b/win32/build/confutils.js
index 3751101daa4..7d9297e8c2d 100644
--- a/win32/build/confutils.js
+++ b/win32/build/confutils.js
@@ -3523,7 +3523,7 @@ function toolset_setup_common_ldflags()
 function toolset_setup_common_libs()
 {
 	// urlmon.lib ole32.lib oleaut32.lib uuid.lib gdi32.lib winspool.lib comdlg32.lib
-	DEFINE("LIBS", "kernel32.lib ole32.lib user32.lib advapi32.lib shell32.lib ws2_32.lib Dnsapi.lib psapi.lib bcrypt.lib Pathcch.lib");
+	DEFINE("LIBS", "kernel32.lib ole32.lib user32.lib advapi32.lib shell32.lib ws2_32.lib Dnsapi.lib psapi.lib bcrypt.lib Pathcch.lib Mswsock.lib");
 }

 function toolset_setup_build_mode()