Commit 9e58a1d9822 for php.net

commit 9e58a1d9822687e42d8e6f3d420979fcb5f4a28e
Author: Louis-Arnaud <la.catoire@gmail.com>
Date:   Mon Sep 7 15:07:47 2026 +0200

    ext/ftp: throw an Error when ftp_nb_fget()/ftp_nb_fput() hit a busy connection (#23540)

    * ext/ftp: throw an Error when the connection is already transferring

    ftp_nb_fget() and ftp_nb_fput() answered an already busy connection with a
    warning and false, against a declared int return type. Reaching that guard
    means a transfer was started from inside another transfer, which is a
    programming mistake, so throw an Error instead, as ftp_close() already does
    on the same in_use flag. The declarations stay int.

    The check stays ahead of the direction and closestream writes, so the
    running transfer is left untouched. The test reaches the guard through a
    stream wrapper that calls back into the extension mid transfer.

    * [skip ci] Note the ftp_nb_fget()/ftp_nb_fput() Error in UPGRADING

    * Address review: extend the Error to ftp_nb_get() and ftp_nb_put()

    The in_use guard is a programming error in all four non-blocking transfer
    functions, so all four now throw the same Error instead of two of them
    emitting a warning and returning false. ftp_nb_get() and ftp_nb_put() keep
    their int|false declaration, which is still returned when the local file
    cannot be opened.

    ftp_nb_put() closes the local stream before throwing, and the guards stay
    ahead of the direction/closestream writes, so a rejected re-entrant call
    leaves the running transfer untouched.

    ftp_nb_get_during_transfer.phpt and ftp_nb_get_during_nb_transfer.phpt
    asserted the old warning; they now record the Error and still assert that
    the outer transfer completes.

    * Catch Throwable in the re-entrant transfer test

    The test asserts the class it prints, so it must not presume Error in the
    catch: a change of thrown class has to fail the test rather than escape it.

diff --git a/UPGRADING b/UPGRADING
index c795bc347df..14dbf5a96f0 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -48,6 +48,12 @@ PHP 8.6 UPGRADE NOTES
     the integer index is greater than INT_MAX instead of overflowing to a
     smaller index.

+- FTP:
+  . ftp_nb_fget(), ftp_nb_fput(), ftp_nb_get() and ftp_nb_put() now throw an
+    Error when the connection is already transferring, instead of emitting a
+    warning and returning false. ftp_close() already throws on the same
+    condition.
+
 - GD:
   . imagesetstyle(), imagefilter() and imagecrop() filter the types / values of
     their array arguments and raise a TypeError / ValueError accordingly.
diff --git a/ext/ftp/php_ftp.c b/ext/ftp/php_ftp.c
index 312f87c2781..31947af455c 100644
--- a/ext/ftp/php_ftp.c
+++ b/ext/ftp/php_ftp.c
@@ -655,8 +655,8 @@ PHP_FUNCTION(ftp_nb_fget)

 	/* configuration */
 	if (ftp->in_use) {
-		php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use");
-		RETURN_FALSE;
+		zend_throw_error(NULL, "Cannot start a transfer while another transfer is in progress");
+		RETURN_THROWS();
 	}

 	ftp->direction = 0;   /* recv */
@@ -770,8 +770,8 @@ PHP_FUNCTION(ftp_nb_get)
 	}
 	GET_FTPBUF(ftp, z_ftp);
 	if (ftp->in_use) {
-		php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use");
-		RETURN_FALSE;
+		zend_throw_error(NULL, "Cannot start a transfer while another transfer is in progress");
+		RETURN_THROWS();
 	}
 	XTYPE(xtype, mode);

@@ -960,8 +960,8 @@ PHP_FUNCTION(ftp_nb_fput)

 	/* configuration */
 	if (ftp->in_use) {
-		php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use");
-		RETURN_FALSE;
+		zend_throw_error(NULL, "Cannot start a transfer while another transfer is in progress");
+		RETURN_THROWS();
 	}

 	ftp->direction = true;   /* send */
@@ -1106,8 +1106,8 @@ PHP_FUNCTION(ftp_nb_put)

 	if (ftp->in_use) {
 		php_stream_close(instream);
-		php_error_docref(NULL, E_WARNING, "FTP\\Connection is already in use");
-		RETURN_FALSE;
+		zend_throw_error(NULL, "Cannot start a transfer while another transfer is in progress");
+		RETURN_THROWS();
 	}

 	/* configuration */
diff --git a/ext/ftp/tests/ftp_nb_get_during_nb_transfer.phpt b/ext/ftp/tests/ftp_nb_get_during_nb_transfer.phpt
index deb1698c77c..c36f63ab0cb 100644
--- a/ext/ftp/tests/ftp_nb_get_during_nb_transfer.phpt
+++ b/ext/ftp/tests/ftp_nb_get_during_nb_transfer.phpt
@@ -10,11 +10,17 @@
 class NbGetDuringNbGet {
     public $context;
     public static $ftp;
+    public static $error;
     public function stream_open($path, $mode, $options, &$opened_path) {
         return true;
     }
     public function stream_write($data) {
-        @ftp_nb_get(self::$ftp, 'php://memory', 'a story.txt', FTP_BINARY);
+        try {
+            ftp_nb_get(self::$ftp, 'php://memory', 'a story.txt', FTP_BINARY);
+        } catch (Throwable $e) {
+            /* recorded rather than echoed: stream_write() may run more than once */
+            self::$error = $e::class . ': ' . $e->getMessage();
+        }
         return strlen($data);
     }
     public function stream_close() {}
@@ -35,10 +41,13 @@ public function stream_eof() {
 }
 var_dump($r === FTP_FINISHED);

+var_dump(NbGetDuringNbGet::$error);
+
 ftp_close($ftp);
 echo "closed\n";
 ?>
 --EXPECT--
 bool(true)
 bool(true)
+string(68) "Error: Cannot start a transfer while another transfer is in progress"
 closed
diff --git a/ext/ftp/tests/ftp_nb_get_during_transfer.phpt b/ext/ftp/tests/ftp_nb_get_during_transfer.phpt
index c7920496feb..e22da607147 100644
--- a/ext/ftp/tests/ftp_nb_get_during_transfer.phpt
+++ b/ext/ftp/tests/ftp_nb_get_during_transfer.phpt
@@ -10,11 +10,17 @@
 class NbGetDuringGet {
     public $context;
     public static $ftp;
+    public static $error;
     public function stream_open($path, $mode, $options, &$opened_path) {
         return true;
     }
     public function stream_write($data) {
-        @ftp_nb_get(self::$ftp, 'php://memory', 'a story.txt', FTP_BINARY);
+        try {
+            ftp_nb_get(self::$ftp, 'php://memory', 'a story.txt', FTP_BINARY);
+        } catch (Throwable $e) {
+            /* recorded rather than echoed: stream_write() may run more than once */
+            self::$error = $e::class . ': ' . $e->getMessage();
+        }
         return strlen($data);
     }
     public function stream_close() {}
@@ -31,10 +37,13 @@ public function stream_eof() {

 var_dump(@ftp_get($ftp, 'reentrantget://sink', 'a story.txt', FTP_BINARY));

+var_dump(NbGetDuringGet::$error);
+
 ftp_close($ftp);
 echo "closed\n";
 ?>
 --EXPECT--
 bool(true)
 bool(true)
+string(68) "Error: Cannot start a transfer while another transfer is in progress"
 closed
diff --git a/ext/ftp/tests/ftp_nb_transfer_during_transfer.phpt b/ext/ftp/tests/ftp_nb_transfer_during_transfer.phpt
new file mode 100644
index 00000000000..db79f1ecea2
--- /dev/null
+++ b/ext/ftp/tests/ftp_nb_transfer_during_transfer.phpt
@@ -0,0 +1,67 @@
+--TEST--
+ftp_nb_fget(), ftp_nb_fput(), ftp_nb_get() and ftp_nb_put() throw when a transfer is already in progress
+--EXTENSIONS--
+ftp
+pcntl
+--FILE--
+<?php
+require 'server.inc';
+
+class TransferDuringNbWrite {
+    public $context;
+    public static $ftp;
+    public static $call;
+    public function stream_open($path, $mode, $options, &$opened_path) {
+        return true;
+    }
+    public function stream_write($data) {
+        try {
+            (self::$call)(self::$ftp);
+        } catch (Throwable $e) {
+            echo $e::class, ': ', $e->getMessage(), "\n";
+        }
+        return strlen($data);
+    }
+    public function stream_close() {}
+    public function stream_eof() {
+        return true;
+    }
+}
+
+stream_wrapper_register('reentrantnb', TransferDuringNbWrite::class);
+
+$ftp = ftp_connect('127.0.0.1', $port);
+var_dump(ftp_login($ftp, 'user', 'pass'));
+TransferDuringNbWrite::$ftp = $ftp;
+
+$sink = fopen('php://memory', 'w+');
+/* ftp_nb_put() opens the local file before it reaches the guard, so it has to exist. */
+$local = __DIR__ . '/ftp_nb_transfer_during_transfer.tmp';
+file_put_contents($local, 'payload');
+
+$calls = [
+    static fn ($ftp) => ftp_nb_fget($ftp, $sink, 'a story.txt', FTP_BINARY),
+    static fn ($ftp) => ftp_nb_fput($ftp, 'a story.txt', $sink, FTP_BINARY),
+    static fn ($ftp) => ftp_nb_get($ftp, $local, 'a story.txt', FTP_BINARY),
+    static fn ($ftp) => ftp_nb_put($ftp, 'a story.txt', $local, FTP_BINARY),
+];
+
+foreach ($calls as $call) {
+    TransferDuringNbWrite::$call = $call;
+    @ftp_nb_get($ftp, 'reentrantnb://sink', 'a story.txt', FTP_BINARY);
+}
+
+ftp_close($ftp);
+echo "closed\n";
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/ftp_nb_transfer_during_transfer.tmp');
+?>
+--EXPECT--
+bool(true)
+Error: Cannot start a transfer while another transfer is in progress
+Error: Cannot start a transfer while another transfer is in progress
+Error: Cannot start a transfer while another transfer is in progress
+Error: Cannot start a transfer while another transfer is in progress
+closed