[php-src] master: sapi/cli: support Expect 100-continue in PHP dev server

Sjoerd Langkemper via David Carlier <[email protected]>
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: Sjoerd Langkemper (Sjord)
Committer: David Carlier (devnexen)
Date: 2026-08-25T19:51:29+01:00

Commit: https://github.com/php/php-src/commit/b8c43c35d11a1d71851d19db3514b6d902ce9903
Raw diff: https://github.com/php/php-src/commit/b8c43c35d11a1d71851d19db3514b6d902ce9903.diff

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

Changed paths:
  A  sapi/cli/tests/php_cli_server_expect_100_continue_curl.phpt
  A  sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt
  A  sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt
  M  NEWS
  M  sapi/cli/php_cli_server.c
  M  sapi/cli/tests/php_cli_server.inc


Diff:

diff --git a/NEWS b/NEWS
index 0817b78f6598..5276069926e5 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 5df12648ca84..9f13f0647700 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 feee2bbb5686..ec370753573c 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 000000000000..37886fc25e1e
--- /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 000000000000..c792630a9305
--- /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 000000000000..0d386155721d
--- /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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.