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()