Commit b8c43c35d11 for php.net

commit b8c43c35d11a1d71851d19db3514b6d902ce9903
Author: Sjoerd Langkemper <sjoerd-github@linuxonly.nl>
Date:   Tue Aug 25 19:51:29 2026 +0100

    sapi/cli: support Expect 100-continue in PHP dev server

    When posting large payloads, curl checks whether the server is ready for
    the body. It sends an `Expect: 100-continue` header and expects
    `HTTP/1.1 100 Continue` as the response before sending the body. The PHP
    development server did not support this, causing a timeout in curl. This
    made such requests take one second longer.

    HTTP/1.0 does not support this, so the response is only sent when the
    request is HTTP/1.1.

    - https://everything.curl.dev/http/post/expect100.html
    - https://github.com/php/php-src/issues/23242

    Fix GH-23242
    Close GH-23245

diff --git a/NEWS b/NEWS
index 0817b78f659..5276069926e 100644
--- a/NEWS
+++ b/NEWS
@@ -2,6 +2,10 @@ PHP                                                                        NEWS
 |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
 ?? ??? ????, PHP 8.6.0beta3

+- CLI:
+  . Fixed bug GH-23242 (PHP development server does not support Expect
+    100-continue flow control). (Sjoerd Langkemper)
+

 27 Aug 2026, PHP 8.6.0beta2

diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c
index 5df12648ca8..9f13f064770 100644
--- a/sapi/cli/php_cli_server.c
+++ b/sapi/cli/php_cli_server.c
@@ -176,6 +176,7 @@ typedef struct php_cli_server_client {
 	bool request_read;
 	bool too_large_post;
 	bool headers_written;
+	bool expect_continue;
 	zend_string *current_header_name;
 	zend_string *current_header_value;
 	enum { HEADER_NONE=0, HEADER_FIELD, HEADER_VALUE } last_header_element;
@@ -1794,6 +1795,13 @@ static int php_cli_server_client_read_request_on_headers_complete(php_http_parse
 		return 2;
 	}

+	zval *expect_val = zend_hash_str_find(&client->request.headers, "expect", sizeof("expect") - 1);
+	if (expect_val && Z_TYPE_P(expect_val) == IS_STRING
+			&& zend_string_equals_literal_ci(Z_STR_P(expect_val), "100-continue")
+			&& parser->http_major == 1 && parser->http_minor == 1) {
+		client->expect_continue = true;
+	}
+
 	return 0;
 }

@@ -1901,6 +1909,23 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha
 		return -1;
 	}

+	if (client->expect_continue && !client->request_read) {
+		/* Parser completed headers with Expect: 100-continue but hasn't
+		 * finished reading the body. Send 100 Continue before the client
+		 * sends the request body. Only supported in HTTP/1.1. */
+		static const char continue_response[] = "HTTP/1.1 100 Continue\r\n\r\n";
+		bool send_success = false;
+		client->expect_continue = false;
+		zend_try {
+			size_t sent = php_cli_server_client_send_through(client, continue_response, strlen(continue_response));
+			send_success = sent == strlen(continue_response);
+		} zend_end_try();
+		if (!send_success) {
+			*errstr = php_socket_strerror(php_socket_errno(), NULL, 0);
+			return -1;
+		}
+	}
+
 	return client->request_read ? 1: 0;
 }
 /* }}} */
@@ -1985,6 +2010,7 @@ static void php_cli_server_client_ctor(php_cli_server_client *client, php_cli_se
 	client->request_read = false;
 	client->too_large_post = false;
 	client->headers_written = false;
+	client->expect_continue = false;

 	client->last_header_element = HEADER_NONE;
 	client->current_header_name = NULL;
diff --git a/sapi/cli/tests/php_cli_server.inc b/sapi/cli/tests/php_cli_server.inc
index feee2bbb568..ec370753573 100644
--- a/sapi/cli/tests/php_cli_server.inc
+++ b/sapi/cli/tests/php_cli_server.inc
@@ -5,6 +5,7 @@ class CliServerInfo {
     public function __construct(
         public string $docRoot,
         public $processHandle,
+        public $outputFile,
     ) {}
 }

@@ -118,7 +119,7 @@ function php_cli_server_start(
     define("PHP_CLI_SERVER_PORT", $port);
     define("PHP_CLI_SERVER_ADDRESS", PHP_CLI_SERVER_HOSTNAME.":".PHP_CLI_SERVER_PORT);

-    return new CliServerInfo($doc_root, $handle);
+    return new CliServerInfo($doc_root, $handle, $output_file);
 }

 function php_cli_server_connect() {
diff --git a/sapi/cli/tests/php_cli_server_expect_100_continue_curl.phpt b/sapi/cli/tests/php_cli_server_expect_100_continue_curl.phpt
new file mode 100644
index 00000000000..37886fc25e1
--- /dev/null
+++ b/sapi/cli/tests/php_cli_server_expect_100_continue_curl.phpt
@@ -0,0 +1,35 @@
+--TEST--
+Expect 100-continue behavior in PHP development server (curl)
+--SKIPIF--
+<?php
+include "skipif.inc";
+?>
+--EXTENSIONS--
+curl
+--FILE--
+<?php
+include 'php_cli_server.inc';
+$server = php_cli_server_start();
+
+// Generate a POST body larger than 1MB to trigger Expect: 100-continue
+$body = str_repeat('A', 1024 * 1024 + 1);
+
+$ch = curl_init();
+curl_setopt($ch, CURLOPT_URL, PHP_CLI_SERVER_ADDRESS);
+curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+
+// Set a high timeout for 100-continue response
+curl_setopt($ch, CURLOPT_EXPECT_100_TIMEOUT_MS, 2000);
+
+curl_exec($ch);
+var_dump(curl_errno($ch));
+
+echo "Did the PHP development server send a HTTP/1.1 100 Continue header?\n";
+$start_transfer_time = curl_getinfo($ch, CURLINFO_STARTTRANSFER_TIME_T);
+var_dump($start_transfer_time < 1_000_000);
+?>
+--EXPECT--
+int(0)
+Did the PHP development server send a HTTP/1.1 100 Continue header?
+bool(true)
diff --git a/sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt b/sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt
new file mode 100644
index 00000000000..c792630a930
--- /dev/null
+++ b/sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt
@@ -0,0 +1,33 @@
+--TEST--
+Failure to send "100 Continue" is reported with ignore_user_abort=1
+--SKIPIF--
+<?php
+include "skipif.inc";
+if (!extension_loaded("sockets")) die("skip sockets extension required");
+if (PHP_OS_FAMILY === "Windows") die("skip SO_LINGER reset behaviour differs on Windows");
+?>
+--FILE--
+<?php
+include "php_cli_server.inc";
+$server = php_cli_server_start('echo "Hello world";', 'index.php', ['-d', 'ignore_user_abort=1']);
+
+$fp = fsockopen(PHP_CLI_SERVER_HOSTNAME, PHP_CLI_SERVER_PORT);
+socket_set_option(socket_import_stream($fp), SOL_SOCKET, SO_LINGER, ['l_onoff' => 1, 'l_linger' => 0]);
+fwrite($fp, "POST / HTTP/1.1\r\nExpect: 100-continue\r\nContent-Length: 4\r\n\r\n");
+fclose($fp);
+
+$output = '';
+for ($i = 0; $i < 100 && !str_contains($output, 'Invalid request'); $i++) {
+    usleep(50000);
+    $output = file_get_contents($server->outputFile);
+}
+
+var_dump(str_contains($output, 'Invalid request'), str_contains($output, 'Unexpected EOF'));
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/php_cli_server_expect_100_continue_iua.log')
+?>
+--EXPECT--
+bool(true)
+bool(false)
diff --git a/sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt b/sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt
new file mode 100644
index 00000000000..0d386155721
--- /dev/null
+++ b/sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt
@@ -0,0 +1,86 @@
+--TEST--
+Expect 100-continue behavior in PHP development server (sockets)
+--SKIPIF--
+<?php
+include "skipif.inc";
+?>
+--FILE--
+<?php
+include "php_cli_server.inc";
+php_cli_server_start();
+
+echo "# Send Expect: 100-continue header, receive 100 Continue response.\n";
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nExpect: 100-continue\r\nContent-Length: 4\r\nConnection: close\r\n\r\n");
+echo fgets($fp);
+echo fgets($fp);
+fwrite($fp, "body");
+echo fgets($fp);
+fclose($fp);
+
+echo "# Send Expect: 100-continue header on HTTP/1.0.\n";
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.0\r\nExpect: 100-continue\r\nContent-Length: 4\r\nConnection: close\r\n\r\n");
+$read = [$fp];
+var_dump(stream_select($read, $write, $except, 0, 1000));
+fwrite($fp, "body");
+echo fgets($fp);
+fclose($fp);
+
+echo "# Send Expect: 100-continue header and disconnect.\n";
+$fp = php_cli_server_connect();
+if (extension_loaded('sockets')) {
+    // Set SO_LINGER timeout to zero so that send fails on the server immediately
+    socket_set_option(
+        socket_import_stream($fp),
+        SOL_SOCKET,
+        SO_LINGER,
+        [
+            'l_onoff' => 1,
+            'l_linger' => 0,
+        ]
+    );
+}
+stream_socket_shutdown($fp, STREAM_SHUT_RD);
+fwrite($fp, "POST / HTTP/1.1\r\nExpect: 100-continue\r\nContent-Length: 4\r\nConnection: close\r\n\r\n");
+fclose($fp);
+
+$fp = php_cli_server_connect();
+fwrite($fp, "GET / HTTP/1.1\r\nConnection: close\r\n\r\n");
+echo fgets($fp);
+fclose($fp);
+
+echo "# GET with Expect header (no body).\n";
+$fp = php_cli_server_connect();
+fwrite($fp, "GET / HTTP/1.1\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n");
+echo fgets($fp);
+fclose($fp);
+
+echo "# POST with empty body.\n";
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nExpect: 100-continue\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+echo fgets($fp);
+fclose($fp);
+
+echo "# Lower-case expect header.\n";
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nexpect: 100-continue\r\nContent-Length: 4\r\nConnection: close\r\n\r\n");
+echo fgets($fp);
+fclose($fp);
+?>
+--EXPECT--
+# Send Expect: 100-continue header, receive 100 Continue response.
+HTTP/1.1 100 Continue
+
+HTTP/1.1 200 OK
+# Send Expect: 100-continue header on HTTP/1.0.
+int(0)
+HTTP/1.0 200 OK
+# Send Expect: 100-continue header and disconnect.
+HTTP/1.1 200 OK
+# GET with Expect header (no body).
+HTTP/1.1 200 OK
+# POST with empty body.
+HTTP/1.1 200 OK
+# Lower-case expect header.
+HTTP/1.1 100 Continue