From 346683bccd9ac7d75f465b10ab384566492baeb3 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Thu, 13 Aug 2026 13:11:32 +0000 Subject: [PATCH 1/8] 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. - https://everything.curl.dev/http/post/expect100.html - https://github.com/php/php-src/issues/23242 --- .../curl_expect_100_continue_timeout.phpt | 31 +++++++++++++++++++ sapi/cli/php_cli_server.c | 20 ++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 ext/curl/tests/curl_expect_100_continue_timeout.phpt diff --git a/ext/curl/tests/curl_expect_100_continue_timeout.phpt b/ext/curl/tests/curl_expect_100_continue_timeout.phpt new file mode 100644 index 000000000000..105f7e5e658a --- /dev/null +++ b/ext/curl/tests/curl_expect_100_continue_timeout.phpt @@ -0,0 +1,31 @@ +--TEST-- +curl with expect 100-continue against PHP development server +--EXTENSIONS-- +curl +--FILE-- + +--EXPECT-- +int(0) +Did the PHP development server send a HTTP/1.1 100 Continue header? +bool(true) diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c index 5df12648ca84..d207d6b965c4 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,12 @@ 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")) { + client->expect_continue = true; + } + return 0; } @@ -1889,6 +1896,18 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha } client->parser.data = client; nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read); + 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. */ + smart_str buffer = { 0 }; + append_http_status_line(&buffer, client->parser.http_major * 100 + client->parser.http_minor, 100, 0); + smart_str_appendl(&buffer, "\r\n", 2); + smart_str_0(&buffer); + php_cli_server_client_send_through(client, ZSTR_VAL(buffer.s), ZSTR_LEN(buffer.s)); + smart_str_free(&buffer); + client->expect_continue = false; + } if (nbytes_consumed != (size_t)nbytes_read && !client->too_large_post) { if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) { if ((buf[0] & 0x80) /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) { @@ -1985,6 +2004,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; From 3eb541a2de9426e362937b8ad01f77aa6ffc3600 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Fri, 14 Aug 2026 14:40:25 +0000 Subject: [PATCH 2/8] Move test to sapi/cli This is not a curl test, but a test of the behavior of the PHP development server. It should thus not be in the curl directory but in the sapi/cli directory. --- .../cli/tests/php_cli_server_expect_100_continue.phpt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename ext/curl/tests/curl_expect_100_continue_timeout.phpt => sapi/cli/tests/php_cli_server_expect_100_continue.phpt (80%) diff --git a/ext/curl/tests/curl_expect_100_continue_timeout.phpt b/sapi/cli/tests/php_cli_server_expect_100_continue.phpt similarity index 80% rename from ext/curl/tests/curl_expect_100_continue_timeout.phpt rename to sapi/cli/tests/php_cli_server_expect_100_continue.phpt index 105f7e5e658a..3f7117a49812 100644 --- a/ext/curl/tests/curl_expect_100_continue_timeout.phpt +++ b/sapi/cli/tests/php_cli_server_expect_100_continue.phpt @@ -1,17 +1,17 @@ --TEST-- -curl with expect 100-continue against PHP development server +Expect 100-continue behavior in PHP development server --EXTENSIONS-- curl --FILE-- Date: Fri, 21 Aug 2026 08:57:49 +0000 Subject: [PATCH 3/8] Add expect/continue test based on sockets --- ..._cli_server_expect_100_continue_curl.phpt} | 6 +- ...cli_server_expect_100_continue_socket.phpt | 62 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) rename sapi/cli/tests/{php_cli_server_expect_100_continue.phpt => php_cli_server_expect_100_continue_curl.phpt} (88%) create mode 100644 sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt diff --git a/sapi/cli/tests/php_cli_server_expect_100_continue.phpt b/sapi/cli/tests/php_cli_server_expect_100_continue_curl.phpt similarity index 88% rename from sapi/cli/tests/php_cli_server_expect_100_continue.phpt rename to sapi/cli/tests/php_cli_server_expect_100_continue_curl.phpt index 3f7117a49812..37886fc25e1e 100644 --- a/sapi/cli/tests/php_cli_server_expect_100_continue.phpt +++ b/sapi/cli/tests/php_cli_server_expect_100_continue_curl.phpt @@ -1,5 +1,9 @@ --TEST-- -Expect 100-continue behavior in PHP development server +Expect 100-continue behavior in PHP development server (curl) +--SKIPIF-- + --EXTENSIONS-- curl --FILE-- 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..33249787e0cd --- /dev/null +++ b/sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt @@ -0,0 +1,62 @@ +--TEST-- +Expect 100-continue behavior in PHP development server (sockets) +--SKIPIF-- + +--FILE-- + +--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 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 From a6447a72b03ccee5c55715e02cc35a20e76ff83c Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Fri, 21 Aug 2026 20:10:08 +0000 Subject: [PATCH 4/8] Add error handling If php_cli_server_client_send_through fails while sending `HTTP/1.1 100 Continue` we don't want the server to exit. --- sapi/cli/php_cli_server.c | 10 +++++++++- .../php_cli_server_expect_100_continue_socket.phpt | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c index d207d6b965c4..321298eef4ac 100644 --- a/sapi/cli/php_cli_server.c +++ b/sapi/cli/php_cli_server.c @@ -1901,12 +1901,20 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha * finished reading the body. Send 100 Continue before the client * sends the request body. */ smart_str buffer = { 0 }; + bool send_failed = true; append_http_status_line(&buffer, client->parser.http_major * 100 + client->parser.http_minor, 100, 0); smart_str_appendl(&buffer, "\r\n", 2); smart_str_0(&buffer); - php_cli_server_client_send_through(client, ZSTR_VAL(buffer.s), ZSTR_LEN(buffer.s)); + zend_try { + php_cli_server_client_send_through(client, ZSTR_VAL(buffer.s), ZSTR_LEN(buffer.s)); + send_failed = false; + } zend_end_try(); smart_str_free(&buffer); client->expect_continue = false; + if (send_failed) { + *errstr = php_socket_strerror(php_socket_errno(), NULL, 0); + return -1; + } } if (nbytes_consumed != (size_t)nbytes_read && !client->too_large_post) { if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) { 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 index 33249787e0cd..ddd7d50d28f7 100644 --- a/sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt +++ b/sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt @@ -20,6 +20,18 @@ 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); From bb71cfe6d73132eca9d642aae74f603779988ea1 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sun, 23 Aug 2026 20:04:38 +0000 Subject: [PATCH 5/8] Check return value of php_cli_server_client_send_through - fix the return value of php_cli_server_client_send_through so that it returns the number of bytes sent. - check that return value, and report error if we didn't send all bytes. - add @devnexen's test. --- sapi/cli/php_cli_server.c | 8 ++-- ...hp_cli_server_expect_100_continue_iua.phpt | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c index 321298eef4ac..894a60381d4f 100644 --- a/sapi/cli/php_cli_server.c +++ b/sapi/cli/php_cli_server.c @@ -1901,17 +1901,17 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha * finished reading the body. Send 100 Continue before the client * sends the request body. */ smart_str buffer = { 0 }; - bool send_failed = true; + bool send_success = false; append_http_status_line(&buffer, client->parser.http_major * 100 + client->parser.http_minor, 100, 0); smart_str_appendl(&buffer, "\r\n", 2); smart_str_0(&buffer); zend_try { - php_cli_server_client_send_through(client, ZSTR_VAL(buffer.s), ZSTR_LEN(buffer.s)); - send_failed = false; + size_t sent = php_cli_server_client_send_through(client, ZSTR_VAL(buffer.s), ZSTR_LEN(buffer.s)); + send_success = sent == ZSTR_LEN(buffer.s); } zend_end_try(); smart_str_free(&buffer); client->expect_continue = false; - if (send_failed) { + if (!send_success) { *errstr = php_socket_strerror(php_socket_errno(), NULL, 0); return -1; } 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..43deafa5019b --- /dev/null +++ b/sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt @@ -0,0 +1,46 @@ +--TEST-- +Failure to send "100 Continue" is reported with ignore_user_abort=1 +--SKIPIF-- + +--FILE-- + STDIN, 1 => $log_fd, 2 => $log_fd], + $pipes, + __DIR__ +); + +$port = null; +for ($i = 0; $i < 100 && $port === null; $i++) { + usleep(50000); + if (preg_match('@://127\.0\.0\.1:(\d+)\) started@', file_get_contents($log), $m)) { + $port = $m[1]; + } +} + +$fp = fsockopen('127.0.0.1', $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($log); +} + +var_dump(str_contains($output, 'Invalid request'), str_contains($output, 'Unexpected EOF')); + +proc_terminate($server); +unlink($log); +?> +--EXPECT-- +bool(true) +bool(false) From 2f0506f958a21abe587243d281091b81c91d5543 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sun, 23 Aug 2026 20:19:35 +0000 Subject: [PATCH 6/8] Clean up test Use php_cli_server.inc. Return the output file to the caller so that we can check the error message in the output. --- sapi/cli/tests/php_cli_server.inc | 3 +- ...hp_cli_server_expect_100_continue_iua.phpt | 29 +++++-------------- 2 files changed, 10 insertions(+), 22 deletions(-) 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_iua.phpt b/sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt index 43deafa5019b..c792630a9305 100644 --- a/sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt +++ b/sapi/cli/tests/php_cli_server_expect_100_continue_iua.phpt @@ -8,24 +8,10 @@ if (PHP_OS_FAMILY === "Windows") die("skip SO_LINGER reset behaviour differs on ?> --FILE-- STDIN, 1 => $log_fd, 2 => $log_fd], - $pipes, - __DIR__ -); +include "php_cli_server.inc"; +$server = php_cli_server_start('echo "Hello world";', 'index.php', ['-d', 'ignore_user_abort=1']); -$port = null; -for ($i = 0; $i < 100 && $port === null; $i++) { - usleep(50000); - if (preg_match('@://127\.0\.0\.1:(\d+)\) started@', file_get_contents($log), $m)) { - $port = $m[1]; - } -} - -$fp = fsockopen('127.0.0.1', $port); +$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); @@ -33,13 +19,14 @@ fclose($fp); $output = ''; for ($i = 0; $i < 100 && !str_contains($output, 'Invalid request'); $i++) { usleep(50000); - $output = file_get_contents($log); + $output = file_get_contents($server->outputFile); } var_dump(str_contains($output, 'Invalid request'), str_contains($output, 'Unexpected EOF')); - -proc_terminate($server); -unlink($log); +?> +--CLEAN-- + --EXPECT-- bool(true) From 0d4332ab82a11b31fd5bc55a9cb02e54c1f0f408 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Sun, 23 Aug 2026 20:25:52 +0000 Subject: [PATCH 7/8] Move code around This does not functionally change anything, but it's nicer to have the `nbytes_consumed` close together. --- sapi/cli/php_cli_server.c | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c index 894a60381d4f..c83b091e3d71 100644 --- a/sapi/cli/php_cli_server.c +++ b/sapi/cli/php_cli_server.c @@ -1896,6 +1896,18 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha } client->parser.data = client; nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read); + if (nbytes_consumed != (size_t)nbytes_read && !client->too_large_post) { + if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) { + if ((buf[0] & 0x80) /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) { + *errstr = estrdup("Unsupported SSL request"); + } else { + *errstr = estrdup("Malformed HTTP request"); + } + } + + 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 @@ -1916,17 +1928,6 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha return -1; } } - if (nbytes_consumed != (size_t)nbytes_read && !client->too_large_post) { - if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) { - if ((buf[0] & 0x80) /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) { - *errstr = estrdup("Unsupported SSL request"); - } else { - *errstr = estrdup("Malformed HTTP request"); - } - } - - return -1; - } return client->request_read ? 1: 0; } From e51a45e7dd15475fc9c577f4b725f30985b0fbec Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Mon, 24 Aug 2026 19:51:41 +0000 Subject: [PATCH 8/8] Only send continue on HTTP/1.1 HTTP/1.0 doesn't support this. Check for HTTP/1.1 before setting client->expect_continue. This also makes it possible to hardcode the response string, which makes this a bit simpler. Add test that checks whether the server send something (100 Continue) using stream_select. We expect that the server did not send anything, since the test uses HTTP/1.0. --- sapi/cli/php_cli_server.c | 17 +++++++---------- ...p_cli_server_expect_100_continue_socket.phpt | 12 ++++++++++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c index c83b091e3d71..9f13f0647700 100644 --- a/sapi/cli/php_cli_server.c +++ b/sapi/cli/php_cli_server.c @@ -1797,7 +1797,8 @@ static int php_cli_server_client_read_request_on_headers_complete(php_http_parse 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")) { + && zend_string_equals_literal_ci(Z_STR_P(expect_val), "100-continue") + && parser->http_major == 1 && parser->http_minor == 1) { client->expect_continue = true; } @@ -1911,18 +1912,14 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha 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. */ - smart_str buffer = { 0 }; + * 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; - append_http_status_line(&buffer, client->parser.http_major * 100 + client->parser.http_minor, 100, 0); - smart_str_appendl(&buffer, "\r\n", 2); - smart_str_0(&buffer); + client->expect_continue = false; zend_try { - size_t sent = php_cli_server_client_send_through(client, ZSTR_VAL(buffer.s), ZSTR_LEN(buffer.s)); - send_success = sent == ZSTR_LEN(buffer.s); + size_t sent = php_cli_server_client_send_through(client, continue_response, strlen(continue_response)); + send_success = sent == strlen(continue_response); } zend_end_try(); - smart_str_free(&buffer); - client->expect_continue = false; if (!send_success) { *errstr = php_socket_strerror(php_socket_errno(), NULL, 0); return -1; 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 index ddd7d50d28f7..0d386155721d 100644 --- a/sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt +++ b/sapi/cli/tests/php_cli_server_expect_100_continue_socket.phpt @@ -18,6 +18,15 @@ 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')) { @@ -64,6 +73,9 @@ fclose($fp); 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).