diff --git a/ext/phar/phar_object.c b/ext/phar/phar_object.c
index c012ac763b841..970a955956fe8 100644
--- a/ext/phar/phar_object.c
+++ b/ext/phar/phar_object.c
@@ -175,7 +175,7 @@ static int phar_file_action(phar_archive_data *phar, phar_entry_info *info, char
sapi_header_op(SAPI_HEADER_REPLACE, &ctr);
efree((void *) ctr.line);
- if (FAILURE == sapi_send_headers()) {
+ if (FAILURE == sapi_send_headers(/* last_headers */ true)) {
zend_bailout();
}
@@ -300,7 +300,7 @@ static void phar_do_403(char *entry, size_t entry_len) /* {{{ */
ctr.line_len = sizeof("HTTP/1.0 403 Access Denied")-1;
ctr.line = "HTTP/1.0 403 Access Denied";
sapi_header_op(SAPI_HEADER_REPLACE, &ctr);
- sapi_send_headers();
+ sapi_send_headers(/* last_headers */ true);
PHPWRITE("\n
\n Access Denied\n \n \n 403 - File ", sizeof("\n \n Access Denied\n \n \n 403 - File ") - 1);
PHPWRITE("Access Denied
\n \n", sizeof("Access Denied
\n \n") - 1);
}
@@ -324,7 +324,7 @@ static void phar_do_404(phar_archive_data *phar, char *fname, size_t fname_len,
ctr.line_len = sizeof("HTTP/1.0 404 Not Found")-1;
ctr.line = "HTTP/1.0 404 Not Found";
sapi_header_op(SAPI_HEADER_REPLACE, &ctr);
- sapi_send_headers();
+ sapi_send_headers(/* last_headers */ true);
PHPWRITE("\n \n File Not Found\n \n \n 404 - File ", sizeof("\n \n File Not Found\n \n \n 404 - File ") - 1);
PHPWRITE("Not Found
\n \n", sizeof("Not Found
\n \n") - 1);
}
@@ -783,7 +783,7 @@ PHP_METHOD(Phar, webPhar)
}
sapi_header_op(SAPI_HEADER_REPLACE, &ctr);
- sapi_send_headers();
+ sapi_send_headers(/* last_headers */ true);
efree((void *) ctr.line);
zend_bailout();
}
diff --git a/ext/standard/basic_functions.stub.php b/ext/standard/basic_functions.stub.php
index 15abe44a0f2b9..4bb1740af7dba 100755
--- a/ext/standard/basic_functions.stub.php
+++ b/ext/standard/basic_functions.stub.php
@@ -520,6 +520,8 @@ function headers_sent(&$filename = null, &$line = null): bool {}
function headers_list(): array {}
+function headers_send_early_and_clear(): bool {}
+
/* {{{ html.c */
function htmlspecialchars(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE, ?string $encoding = null, bool $double_encode = true): string {}
diff --git a/ext/standard/basic_functions_arginfo.h b/ext/standard/basic_functions_arginfo.h
index 8e06402c0c536..43e9da96512ed 100644
--- a/ext/standard/basic_functions_arginfo.h
+++ b/ext/standard/basic_functions_arginfo.h
@@ -1,5 +1,5 @@
/* This is a generated file, edit the .stub.php file instead.
- * Stub hash: 810b8bfbdf037702fcaec2ff81998c2bc2cefae8 */
+ * Stub hash: f09f8df1b2704720615642414ea106471b139e33 */
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_set_time_limit, 0, 1, _IS_BOOL, 0)
ZEND_ARG_TYPE_INFO(0, seconds, IS_LONG, 0)
@@ -770,6 +770,8 @@ ZEND_END_ARG_INFO()
#define arginfo_headers_list arginfo_ob_list_handlers
+#define arginfo_headers_send_early_and_clear arginfo_ob_flush
+
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_htmlspecialchars, 0, 1, IS_STRING, 0)
ZEND_ARG_TYPE_INFO(0, string, IS_STRING, 0)
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, flags, IS_LONG, 0, "ENT_QUOTES | ENT_SUBSTITUTE")
@@ -2445,6 +2447,7 @@ ZEND_FUNCTION(setcookie);
ZEND_FUNCTION(http_response_code);
ZEND_FUNCTION(headers_sent);
ZEND_FUNCTION(headers_list);
+ZEND_FUNCTION(headers_send_early_and_clear);
ZEND_FUNCTION(htmlspecialchars);
ZEND_FUNCTION(htmlspecialchars_decode);
ZEND_FUNCTION(html_entity_decode);
@@ -3080,6 +3083,7 @@ static const zend_function_entry ext_functions[] = {
ZEND_FE(http_response_code, arginfo_http_response_code)
ZEND_FE(headers_sent, arginfo_headers_sent)
ZEND_FE(headers_list, arginfo_headers_list)
+ ZEND_FE(headers_send_early_and_clear, arginfo_headers_send_early_and_clear)
ZEND_FE(htmlspecialchars, arginfo_htmlspecialchars)
ZEND_FE(htmlspecialchars_decode, arginfo_htmlspecialchars_decode)
ZEND_FE(html_entity_decode, arginfo_html_entity_decode)
diff --git a/ext/standard/head.c b/ext/standard/head.c
index ab64fecf381aa..2311a0e7e36e9 100644
--- a/ext/standard/head.c
+++ b/ext/standard/head.c
@@ -69,7 +69,7 @@ PHP_FUNCTION(header_remove)
PHPAPI int php_header(void)
{
- if (sapi_send_headers()==FAILURE || SG(request_info).headers_only) {
+ if (sapi_send_headers(/* last_headers */ true) == FAILURE || SG(request_info).headers_only) {
return 0; /* don't allow output */
} else {
return 1; /* allow output */
@@ -383,3 +383,21 @@ PHP_FUNCTION(http_response_code)
RETURN_LONG(SG(sapi_headers).http_response_code);
}
/* }}} */
+
+PHP_FUNCTION(headers_send_early_and_clear)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ if (SG(headers_sent)) {
+ php_error_docref(NULL, E_WARNING, "Headers already sent");
+ RETURN_FALSE;
+ }
+
+ if (sapi_send_headers(/* last_header */ false) == FAILURE) {
+ RETURN_FALSE;
+ }
+
+ zend_llist_clean(&SG(sapi_headers).headers);
+ SG(sapi_headers).http_response_code = 200;
+ RETURN_TRUE;
+}
diff --git a/ext/standard/tests/general_functions/headers_send_early_hint.phpt b/ext/standard/tests/general_functions/headers_send_early_hint.phpt
new file mode 100644
index 0000000000000..169a5482c2ed6
--- /dev/null
+++ b/ext/standard/tests/general_functions/headers_send_early_hint.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Using headers_send_early_and_clear() for HTTP early hints (no extra headers)
+--CGI--
+--FILE--
+; rel=preload; as=style');
+headers_send_early_and_clear();
+// Headers should be cleared.
+var_dump(headers_list());
+?>
+--EXPECTHEADERS--
+Status: 103 Early Hints
+Link: ; rel=preload; as=style
+--EXPECT--
+Content-type: text/html; charset=UTF-8
+
+array(0) {
+}
diff --git a/ext/standard/tests/general_functions/headers_send_early_hint_2.phpt b/ext/standard/tests/general_functions/headers_send_early_hint_2.phpt
new file mode 100644
index 0000000000000..2b4b43629d8c5
--- /dev/null
+++ b/ext/standard/tests/general_functions/headers_send_early_hint_2.phpt
@@ -0,0 +1,21 @@
+--TEST--
+Using headers_send_early_and_clear() for HTTP early hints (extra headers)
+--CGI--
+--FILE--
+; rel=preload; as=style');
+headers_send_early_and_clear();
+header('Location: http://example.com/');
+echo "Foo\n";
+?>
+--EXPECTHEADERS--
+Status: 103 Early Hints
+Link: ; rel=preload; as=style
+--EXPECT--
+Status: 302 Found
+Location: http://example.com/
+Content-type: text/html; charset=UTF-8
+
+Foo
diff --git a/ext/standard/tests/general_functions/headers_send_early_hint_3.phpt b/ext/standard/tests/general_functions/headers_send_early_hint_3.phpt
new file mode 100644
index 0000000000000..dd0caa117c083
--- /dev/null
+++ b/ext/standard/tests/general_functions/headers_send_early_hint_3.phpt
@@ -0,0 +1,14 @@
+--TEST--
+Using headers_send_early_and_clear() for HTTP early hints (after output)
+--CGI--
+--FILE--
+
+--EXPECTF--
+Foo
+
+Warning: headers_send_early_and_clear(): Headers already sent in %s on line %d
+bool(false)
diff --git a/main/SAPI.c b/main/SAPI.c
index d3cc29ec02394..bf5b8bda03dee 100644
--- a/main/SAPI.c
+++ b/main/SAPI.c
@@ -821,7 +821,7 @@ SAPI_API int sapi_header_op(sapi_header_op_enum op, void *arg)
}
-SAPI_API int sapi_send_headers(void)
+SAPI_API int sapi_send_headers(bool last_headers)
{
int retval;
int ret = FAILURE;
@@ -833,7 +833,7 @@ SAPI_API int sapi_send_headers(void)
/* Success-oriented. We set headers_sent to 1 here to avoid an infinite loop
* in case of an error situation.
*/
- if (SG(sapi_headers).send_default_content_type && sapi_module.send_headers) {
+ if (SG(sapi_headers).send_default_content_type && sapi_module.send_headers && last_headers) {
uint32_t len = 0;
char *default_mimetype = get_default_content_type(0, &len);
@@ -863,7 +863,7 @@ SAPI_API int sapi_send_headers(void)
zval_ptr_dtor(&cb);
}
- SG(headers_sent) = 1;
+ SG(headers_sent) = last_headers;
if (sapi_module.send_headers) {
retval = sapi_module.send_headers(&SG(sapi_headers));
diff --git a/main/SAPI.h b/main/SAPI.h
index 97c52c41eccce..258c16b42a6c2 100644
--- a/main/SAPI.h
+++ b/main/SAPI.h
@@ -181,7 +181,7 @@ SAPI_API int sapi_add_header_ex(const char *header_line, size_t header_line_len,
#define sapi_add_header(a, b, c) sapi_add_header_ex((a),(b),(c),1)
-SAPI_API int sapi_send_headers(void);
+SAPI_API int sapi_send_headers(bool last_headers);
SAPI_API void sapi_free_header(sapi_header_struct *sapi_header);
SAPI_API void sapi_handle_post(void *arg);
SAPI_API size_t sapi_read_post_block(char *buffer, size_t buflen);
diff --git a/run-tests.php b/run-tests.php
index e046857c66e82..e1a88cc65e99d 100755
--- a/run-tests.php
+++ b/run-tests.php
@@ -2513,7 +2513,7 @@ function run_test(string $php, $file, array $env): string
$headers = [];
if (!empty($uses_cgi) && preg_match("/^(.*?)\r?\n\r?\n(.*)/s", $out, $match)) {
- $output = trim($match[2]);
+ $output = str_replace("\r\n", "\n", trim($match[2]));
$rh = preg_split("/[\n\r]+/", $match[1]);
foreach ($rh as $line) {
diff --git a/sapi/apache2handler/sapi_apache2.c b/sapi/apache2handler/sapi_apache2.c
index f569e79a848e7..78020d5b9fcdb 100644
--- a/sapi/apache2handler/sapi_apache2.c
+++ b/sapi/apache2handler/sapi_apache2.c
@@ -295,7 +295,7 @@ php_apache_sapi_flush(void *server_context)
r = ctx->r;
- sapi_send_headers();
+ sapi_send_headers(/* last_headers */ true);
r->status = SG(sapi_headers).http_response_code;
SG(headers_sent) = 1;
diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c
index 32f14f3cfbfd1..062c7892f57a1 100644
--- a/sapi/cli/php_cli_server.c
+++ b/sapi/cli/php_cli_server.c
@@ -524,7 +524,7 @@ static void sapi_cli_server_flush(void *server_context) /* {{{ */
}
if (!SG(headers_sent)) {
- sapi_send_headers();
+ sapi_send_headers(/* last_headers */ true);
SG(headers_sent) = 1;
}
} /* }}} */
diff --git a/sapi/cli/tests/php_cli_server_early_hints.phpt b/sapi/cli/tests/php_cli_server_early_hints.phpt
new file mode 100644
index 0000000000000..0467393841d6b
--- /dev/null
+++ b/sapi/cli/tests/php_cli_server_early_hints.phpt
@@ -0,0 +1,47 @@
+--TEST--
+PHP CLI server HTTP early hints
+--SKIPIF--
+
+--FILE--
+; rel=preload; as=style');
+ headers_send_early_and_clear();
+ header('Location: http://example.com/');
+ echo "Foo\n";
+ PHP);
+
+$host = PHP_CLI_SERVER_HOSTNAME;
+$fp = php_cli_server_connect();
+
+if (fwrite($fp, <<
+--EXPECTF--
+HTTP/1.1 103 Early Hints
+Host: %s
+Date: %s
+Connection: close
+X-Powered-By: PHP/%s
+Link: ; rel=preload; as=style
+
+HTTP/1.1 302 Found
+Host: localhost
+Date: %s
+Connection: close
+Location: http://example.com/
+Content-type: text/html; charset=UTF-8
+
+Foo