diff --git a/NEWS b/NEWS
index 190c6c16a935..9807e07945df 100644
--- a/NEWS
+++ b/NEWS
@@ -14,6 +14,12 @@ PHP NEWS
. Fixed bug GH-22602 (gregoriantojd() and juliantojd() integer overflow with
INT_MAX year). (arshidkv12)
+- Curl:
+ . Added CURLOPT_SEEKFUNCTION and the CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL
+ and CURL_SEEKFUNC_CANTSEEK constants, letting libcurl rewind a streamed
+ request body to resend it on a redirect, multi-pass authentication or a
+ retried reused connection. (GrahamCampbell)
+
- Date:
. Update timelib to 2022.17. (Derick)
. Fixed bug GH-19803 (Parsing a string with a single white space does create
@@ -35,6 +41,8 @@ PHP NEWS
returning the first entity or notation. (Weilin Du)
. Fixed bug GH-22623 (use after free with namespace nodes from
XSLTProcessor::registerFunctions())/ (David Carlier)
+ . Fixed bug GH-22554 (use-after-free with XPath callback returning a node
+ from a foreign document). (David Carlier)
- Exif:
. Fixed bug GH-11020 (exif_read_data() emits a spurious "Illegal IFD size"
@@ -52,6 +60,9 @@ PHP NEWS
IntlDateFormatter::localtime()/datefmt_localtime() now raise TypeError
when the offset argument is not of type int. (Weilin Du)
+- JSON:
+ . Report unterminated JSON strings as syntax errors. (timwolla)
+
- Opcache:
. Fixed bug GH-21770 (Infinite recursion in property hook getter in opcache
preloaded trait). (iliaal)
@@ -91,6 +102,12 @@ PHP NEWS
. Fixed bug GH-21468 (Segfault in file_get_contents w/ a https URL
and a proxy set). (CVE-2026-12184) (ndossche)
+- URI:
+ . Fixed bug GH-22628 (Percent-encoding of caret in WHATWG URL paths is not
+ performed). (kocsismate)
+ . Fixed bug GH-22629 (WHATWG Validation error incorrect with empty host and
+ non-empty userinfo). (kocsismate)
+
- Zip:
. Fixed bug GH-22649 (ZipArchive::setCommentName() and setCommentIndex()
could crash after overwriting an entry and resetting its inherited
diff --git a/UPGRADING b/UPGRADING
index 031b6751d853..a809687d1961 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -238,6 +238,12 @@ PHP 8.6 UPGRADE NOTES
This value can also be obtained by passing CURLINFO_SIZE_DELIVERED as the
$option parameter.
Requires libcurl 8.20.0 or later.
+ . Added CURLOPT_SEEKFUNCTION to register a callback that repositions a
+ streamed request body so libcurl can rewind and resend it on a redirect,
+ multi-pass authentication, or a retried reused connection instead of
+ failing with CURLE_SEND_FAIL_REWIND. The callback receives the CurlHandle,
+ offset and origin, and must return one of CURL_SEEKFUNC_OK,
+ CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK.
- Fileinfo:
. finfo_file() now works with remote streams.
@@ -467,6 +473,10 @@ PHP 8.6 UPGRADE NOTES
- Curl:
. CURLINFO_SIZE_DELIVERED (libcurl >= 8.20.0).
+ . CURLOPT_SEEKFUNCTION.
+ . CURL_SEEKFUNC_OK.
+ . CURL_SEEKFUNC_FAIL.
+ . CURL_SEEKFUNC_CANTSEEK.
- Sockets:
. TCP_USER_TIMEOUT (Linux only).
diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS
index bdf874b382d0..7775c9316040 100644
--- a/UPGRADING.INTERNALS
+++ b/UPGRADING.INTERNALS
@@ -173,6 +173,14 @@ PHP 8.6 INTERNALS UPGRADE NOTES
. php_idate() now returns the result state, and moves the return value into an
out parameter.
+- ext/intl:
+ . Added intl_icu_compat.h with helpers and feature macros for ICU
+ version-specific API differences. Code in ext/intl should use the
+ intl_icu_compat_* helpers and INTL_ICU_HAS_* macros instead of adding
+ direct U_ICU_VERSION_* guards for supported ICU API variants.
+ . The internal grapheme_get_break_iterator() helper no longer accepts a
+ stack buffer argument; pass only the UErrorCode* status argument.
+
- ext/mbstring:
. Added GB18030-2022 to default encoding list for zh-CN.
diff --git a/ext/curl/curl.stub.php b/ext/curl/curl.stub.php
index d3bd58510e40..70e87cc9b146 100644
--- a/ext/curl/curl.stub.php
+++ b/ext/curl/curl.stub.php
@@ -343,6 +343,11 @@
* @cvalue CURLOPT_RETURNTRANSFER
*/
const CURLOPT_RETURNTRANSFER = UNKNOWN;
+/**
+ * @var int
+ * @cvalue CURLOPT_SEEKFUNCTION
+ */
+const CURLOPT_SEEKFUNCTION = UNKNOWN;
/**
* @var int
* @cvalue CURLOPT_SHARE
@@ -1788,6 +1793,21 @@
* @cvalue CURL_READFUNC_PAUSE
*/
const CURL_READFUNC_PAUSE = UNKNOWN;
+/**
+ * @var int
+ * @cvalue CURL_SEEKFUNC_OK
+ */
+const CURL_SEEKFUNC_OK = UNKNOWN;
+/**
+ * @var int
+ * @cvalue CURL_SEEKFUNC_FAIL
+ */
+const CURL_SEEKFUNC_FAIL = UNKNOWN;
+/**
+ * @var int
+ * @cvalue CURL_SEEKFUNC_CANTSEEK
+ */
+const CURL_SEEKFUNC_CANTSEEK = UNKNOWN;
/**
* @var int
* @cvalue CURL_WRITEFUNC_PAUSE
diff --git a/ext/curl/curl_arginfo.h b/ext/curl/curl_arginfo.h
index 6a05b73572cb..f2929f60c4e2 100644
--- a/ext/curl/curl_arginfo.h
+++ b/ext/curl/curl_arginfo.h
@@ -1,5 +1,5 @@
/* This is a generated file, edit curl.stub.php instead.
- * Stub hash: 1ecf692ba8494e7b486986c0170686c33768deb5 */
+ * Stub hash: d55adb230c533f4dde05e95759477dd9e1dd6efb */
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_curl_close, 0, 1, IS_VOID, 0)
ZEND_ARG_OBJ_INFO(0, handle, CurlHandle, 0)
@@ -294,6 +294,7 @@ static void register_curl_symbols(int module_number)
REGISTER_LONG_CONSTANT("CURLOPT_REFERER", CURLOPT_REFERER, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURLOPT_RESUME_FROM", CURLOPT_RESUME_FROM, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURLOPT_RETURNTRANSFER", CURLOPT_RETURNTRANSFER, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("CURLOPT_SEEKFUNCTION", CURLOPT_SEEKFUNCTION, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURLOPT_SHARE", CURLOPT_SHARE, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURLOPT_SSLCERT", CURLOPT_SSLCERT, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURLOPT_SSLCERTPASSWD", CURLOPT_SSLCERTPASSWD, CONST_PERSISTENT);
@@ -574,6 +575,9 @@ static void register_curl_symbols(int module_number)
REGISTER_LONG_CONSTANT("CURLPAUSE_SEND", CURLPAUSE_SEND, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURLPAUSE_SEND_CONT", CURLPAUSE_SEND_CONT, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURL_READFUNC_PAUSE", CURL_READFUNC_PAUSE, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_OK", CURL_SEEKFUNC_OK, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_FAIL", CURL_SEEKFUNC_FAIL, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_CANTSEEK", CURL_SEEKFUNC_CANTSEEK, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURL_WRITEFUNC_PAUSE", CURL_WRITEFUNC_PAUSE, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURLPROXY_SOCKS4A", CURLPROXY_SOCKS4A, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("CURLPROXY_SOCKS5_HOSTNAME", CURLPROXY_SOCKS5_HOSTNAME, CONST_PERSISTENT);
diff --git a/ext/curl/curl_private.h b/ext/curl/curl_private.h
index 77b0628ee42a..25cc37b6e458 100644
--- a/ext/curl/curl_private.h
+++ b/ext/curl/curl_private.h
@@ -74,6 +74,7 @@ typedef struct {
php_curl_write *write_header;
php_curl_read *read;
zval std_err;
+ zend_fcall_info_cache seek;
zend_fcall_info_cache progress;
zend_fcall_info_cache xferinfo;
zend_fcall_info_cache fnmatch;
diff --git a/ext/curl/interface.c b/ext/curl/interface.c
index 09c7009068b6..b7ec015abf62 100644
--- a/ext/curl/interface.c
+++ b/ext/curl/interface.c
@@ -456,6 +456,10 @@ static HashTable *curl_get_gc(zend_object *object, zval **table, int *n)
zend_get_gc_buffer_add_zval(gc_buffer, &curl->handlers.write_header->stream);
}
+ if (ZEND_FCC_INITIALIZED(curl->handlers.seek)) {
+ zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.seek);
+ }
+
if (ZEND_FCC_INITIALIZED(curl->handlers.progress)) {
zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.progress);
}
@@ -831,6 +835,52 @@ static size_t curl_read(char *data, size_t size, size_t nmemb, void *ctx)
}
/* }}} */
+/* {{{ curl_seek */
+static int curl_seek(void *clientp, curl_off_t offset, int origin)
+{
+ php_curl *ch = (php_curl *)clientp;
+ int rval = CURL_SEEKFUNC_CANTSEEK; /* safe default if unset or the callback misbehaves */
+
+#if PHP_CURL_DEBUG
+ fprintf(stderr, "curl_seek() called\n");
+ fprintf(stderr, "clientp = %x, offset = %ld, origin = %d\n", clientp, offset, origin);
+#endif
+ if (!ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
+ return rval;
+ }
+
+ zval args[3];
+ zval retval;
+
+ GC_ADDREF(&ch->std);
+ ZVAL_OBJ(&args[0], &ch->std);
+ ZVAL_LONG(&args[1], offset);
+ ZVAL_LONG(&args[2], origin);
+
+ ch->in_callback = true;
+ zend_call_known_fcc(&ch->handlers.seek, &retval, /* param_count */ 3, args, /* named_params */ NULL);
+ ch->in_callback = false;
+
+ if (!Z_ISUNDEF(retval)) {
+ _php_curl_verify_handlers(ch, /* reporterror */ true);
+ if (Z_TYPE(retval) == IS_LONG) {
+ zend_long retval_long = Z_LVAL(retval);
+ if (retval_long == CURL_SEEKFUNC_OK || retval_long == CURL_SEEKFUNC_FAIL || retval_long == CURL_SEEKFUNC_CANTSEEK) {
+ rval = retval_long;
+ } else {
+ zend_value_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK");
+ }
+ } else {
+ zend_type_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK");
+ }
+ zval_ptr_dtor(&retval);
+ }
+
+ zval_ptr_dtor(&args[0]);
+ return rval;
+}
+/* }}} */
+
/* {{{ curl_write_header */
static size_t curl_write_header(char *data, size_t size, size_t nmemb, void *ctx)
{
@@ -1038,6 +1088,7 @@ void init_curl_handle(php_curl *ch)
ch->handlers.write = ecalloc(1, sizeof(php_curl_write));
ch->handlers.write_header = ecalloc(1, sizeof(php_curl_write));
ch->handlers.read = ecalloc(1, sizeof(php_curl_read));
+ ch->handlers.seek = empty_fcall_info_cache;
ch->handlers.progress = empty_fcall_info_cache;
ch->handlers.xferinfo = empty_fcall_info_cache;
ch->handlers.fnmatch = empty_fcall_info_cache;
@@ -1208,6 +1259,7 @@ void _php_setup_easy_copy_handlers(php_curl *ch, php_curl *source)
curl_easy_setopt(ch->cp, CURLOPT_WRITEHEADER, (void *) ch);
curl_easy_setopt(ch->cp, CURLOPT_DEBUGDATA, (void *) ch);
+ php_curl_copy_fcc_with_option(ch, CURLOPT_SEEKDATA, &ch->handlers.seek, &source->handlers.seek);
php_curl_copy_fcc_with_option(ch, CURLOPT_PROGRESSDATA, &ch->handlers.progress, &source->handlers.progress);
php_curl_copy_fcc_with_option(ch, CURLOPT_XFERINFODATA, &ch->handlers.xferinfo, &source->handlers.xferinfo);
php_curl_copy_fcc_with_option(ch, CURLOPT_FNMATCH_DATA, &ch->handlers.fnmatch, &source->handlers.fnmatch);
@@ -1577,6 +1629,7 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue
HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_HEADER, write_header, PHP_CURL_IGNORE);
HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_READ, read, PHP_CURL_DIRECT);
+ HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_SEEK, handlers.seek, curl_seek);
HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_PROGRESS, handlers.progress, curl_progress);
HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_XFERINFO, handlers.xferinfo, curl_xferinfo);
HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_FNMATCH_, handlers.fnmatch, curl_fnmatch);
@@ -2794,6 +2847,9 @@ static void curl_free_obj(zend_object *object)
efree(ch->handlers.write_header);
efree(ch->handlers.read);
+ if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
+ zend_fcc_dtor(&ch->handlers.seek);
+ }
if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) {
zend_fcc_dtor(&ch->handlers.progress);
}
@@ -2878,6 +2934,10 @@ static void _php_curl_reset_handlers(php_curl *ch)
ZVAL_UNDEF(&ch->handlers.std_err);
}
+ if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) {
+ zend_fcc_dtor(&ch->handlers.seek);
+ }
+
if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) {
zend_fcc_dtor(&ch->handlers.progress);
}
diff --git a/ext/curl/tests/curl_copy_handle_seek.phpt b/ext/curl/tests/curl_copy_handle_seek.phpt
new file mode 100644
index 000000000000..c2a2f37bbe13
--- /dev/null
+++ b/ext/curl/tests/curl_copy_handle_seek.phpt
@@ -0,0 +1,44 @@
+--TEST--
+Test curl_copy_handle() with CURLOPT_SEEKFUNCTION
+--EXTENSIONS--
+curl
+--FILE--
+ 0);
+var_dump(str_contains($response, $body));
+?>
+--EXPECT--
+bool(true)
+bool(true)
diff --git a/ext/curl/tests/curl_seekfunction.phpt b/ext/curl/tests/curl_seekfunction.phpt
new file mode 100644
index 000000000000..d2daa1e7d8dc
--- /dev/null
+++ b/ext/curl/tests/curl_seekfunction.phpt
@@ -0,0 +1,51 @@
+--TEST--
+CURLOPT_SEEKFUNCTION is called to rewind a streamed upload across a redirect
+--EXTENSIONS--
+curl
+--FILE--
+ 0);
+var_dump(str_contains($response, $body));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/curl/tests/curl_seekfunction_error.phpt b/ext/curl/tests/curl_seekfunction_error.phpt
new file mode 100644
index 000000000000..134e8115dc5f
--- /dev/null
+++ b/ext/curl/tests/curl_seekfunction_error.phpt
@@ -0,0 +1,77 @@
+--TEST--
+CURLOPT_SEEKFUNCTION callback error handling and option validation
+--EXTENSIONS--
+curl
+--FILE--
+ 'not an int');
+} catch (\TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+echo "\nReturning an out-of-range int:\n";
+try {
+ run_upload($host, fn($ch, $offset, $origin) => 42);
+} catch (\ValueError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+echo "\nThrowing from the callback:\n";
+try {
+ run_upload($host, function ($ch, $offset, $origin) {
+ throw new \RuntimeException('boom from seek');
+ });
+} catch (\RuntimeException $e) {
+ echo $e->getMessage(), "\n";
+}
+
+echo "\nSetting the callback to null:\n";
+var_dump(curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, null));
+
+echo "\nSetting a non-callable scalar:\n";
+try {
+ curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, 42);
+} catch (\TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+Returning a non-int:
+The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK
+
+Returning an out-of-range int:
+The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK
+
+Throwing from the callback:
+boom from seek
+
+Setting the callback to null:
+bool(true)
+
+Setting a non-callable scalar:
+curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, no array or string given
diff --git a/ext/curl/tests/curl_setopt_callables.phpt b/ext/curl/tests/curl_setopt_callables.phpt
index aaa83102afac..9de8d6705710 100644
--- a/ext/curl/tests/curl_setopt_callables.phpt
+++ b/ext/curl/tests/curl_setopt_callables.phpt
@@ -27,6 +27,7 @@ testOption($ch, CURLOPT_FNMATCH_FUNCTION);
testOption($ch, CURLOPT_WRITEFUNCTION);
testOption($ch, CURLOPT_HEADERFUNCTION);
testOption($ch, CURLOPT_READFUNCTION);
+testOption($ch, CURLOPT_SEEKFUNCTION);
?>
--EXPECT--
@@ -42,3 +43,5 @@ TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for opti
TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_HEADERFUNCTION, function "undefined" not found or invalid function name
TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_READFUNCTION, function "undefined" not found or invalid function name
TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_READFUNCTION, function "undefined" not found or invalid function name
+TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, function "undefined" not found or invalid function name
+TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_SEEKFUNCTION, function "undefined" not found or invalid function name
diff --git a/ext/curl/tests/responder/get.inc b/ext/curl/tests/responder/get.inc
index c139c8c7d43a..2ef1e4a89dd3 100644
--- a/ext/curl/tests/responder/get.inc
+++ b/ext/curl/tests/responder/get.inc
@@ -46,6 +46,11 @@
case 'method':
echo $_SERVER['REQUEST_METHOD'];
break;
+ case 'redirect':
+ // A 307 preserves the method and body, so libcurl must rewind the upload
+ // (via CURLOPT_SEEKFUNCTION) before resending it to the new location.
+ header('Location: /get.inc?test=input', true, 307);
+ break;
default:
echo "Hello World!\n";
echo "Hello World!";
diff --git a/ext/dom/tests/gh22554.phpt b/ext/dom/tests/gh22554.phpt
new file mode 100644
index 000000000000..c8db1d87390b
--- /dev/null
+++ b/ext/dom/tests/gh22554.phpt
@@ -0,0 +1,38 @@
+--TEST--
+GH-22554 (Use-after-free when an XPath callback returns a node from a document created inside the callback)
+--CREDITS--
+waseem-cve
+--EXTENSIONS--
+dom
+--FILE--
+loadXML('');
+
+$xp = new DOMXPath($doc);
+$xp->registerNamespace('my', 'my.ns');
+
+$xp->registerPHPFunctionNS('my.ns', 'include', function () {
+ $d = new DOMDocument;
+ $d->loadXML('');
+
+ return $d->documentElement;
+});
+
+$xp->registerPHPFunctionNS('my.ns', 'process', function ($arg) {
+ echo "process arg: ", get_class($arg[0]), " ", $arg[0]->nodeName, "\n";
+ return 'x';
+});
+
+$result = $xp->query('my:process(my:include()/uaf)');
+var_dump($result->length);
+unset($xp);
+
+echo "Done\n";
+
+?>
+--EXPECT--
+process arg: DOMElement uaf
+int(0)
+Done
diff --git a/ext/dom/xpath.c b/ext/dom/xpath.c
index bd9947b4ad10..dc9e1b852fae 100644
--- a/ext/dom/xpath.c
+++ b/ext/dom/xpath.c
@@ -77,7 +77,8 @@ static void dom_xpath_proxy_factory(xmlNodePtr node, zval *child, dom_object *in
ZEND_ASSERT(node->type != XML_NAMESPACE_DECL);
- php_dom_create_object(node, child, intern);
+ dom_xpath_object *xobj = php_xpath_obj_from_obj(&intern->std);
+ php_dom_create_object(node, child, dom_xpath_intern_for_doc(xobj, node->doc));
}
static dom_xpath_object *dom_xpath_ext_fetch_intern(xmlXPathParserContextPtr ctxt)
diff --git a/ext/json/json_scanner.re b/ext/json/json_scanner.re
index e4d25009132a..0c64a6423baf 100644
--- a/ext/json/json_scanner.re
+++ b/ext/json/json_scanner.re
@@ -262,7 +262,14 @@ std:
s->errcode = PHP_JSON_ERROR_UTF8;
return PHP_JSON_T_ERROR;
}
-
+ EOI {
+ if (s->limit < s->cursor) {
+ s->errcode = PHP_JSON_ERROR_SYNTAX;
+ } else {
+ s->errcode = PHP_JSON_ERROR_CTRL_CHAR;
+ }
+ return PHP_JSON_T_ERROR;
+ }
CTRL {
s->errcode = PHP_JSON_ERROR_CTRL_CHAR;
return PHP_JSON_T_ERROR;
diff --git a/ext/json/tests/gh22527.phpt b/ext/json/tests/gh22527.phpt
new file mode 100644
index 000000000000..cb0dd982d50b
--- /dev/null
+++ b/ext/json/tests/gh22527.phpt
@@ -0,0 +1,31 @@
+--TEST--
+GH-22527: Unterminated JSON strings are misleadingly reported as “Control character error”
+--FILE--
+
+--EXPECT--
+NULL
+int(4)
+string(30) "Syntax error near location 1:1"
+NULL
+int(4)
+string(30) "Syntax error near location 1:1"
+NULL
+int(4)
+string(30) "Syntax error near location 1:9"
+NULL
+int(3)
+string(71) "Control character error, possibly incorrectly encoded near location 1:1"
diff --git a/ext/json/tests/json_last_error_msg_error_location_001.phpt b/ext/json/tests/json_last_error_msg_error_location_001.phpt
index e0553f9f7d65..72cb1c42f23f 100644
--- a/ext/json/tests/json_last_error_msg_error_location_001.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_001.phpt
@@ -66,8 +66,8 @@ string(30) "Syntax error near location 1:1"
Error at position 1:10:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error at position 1:9:
bool(false)
@@ -118,4 +118,3 @@ Error at position 1:10:
bool(false)
int(3)
string(72) "Control character error, possibly incorrectly encoded near location 1:10"
-
diff --git a/ext/json/tests/json_last_error_msg_error_location_002.phpt b/ext/json/tests/json_last_error_msg_error_location_002.phpt
index df7fc981ccba..31438255b31a 100644
--- a/ext/json/tests/json_last_error_msg_error_location_002.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_002.phpt
@@ -53,51 +53,50 @@ Testing error locations with Unicode UTF-8 characters
Error after Japanese characters:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:12"
+int(4)
+string(31) "Syntax error near location 1:12"
Error after Russian characters:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:9"
+int(4)
+string(30) "Syntax error near location 1:9"
Error after Chinese characters:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:8"
+int(4)
+string(30) "Syntax error near location 1:8"
Error after Arabic characters:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:9"
+int(4)
+string(30) "Syntax error near location 1:9"
Error after Emoji:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:11"
+int(4)
+string(31) "Syntax error near location 1:11"
Error in mixed ASCII and UTF-8:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:27"
+int(4)
+string(31) "Syntax error near location 1:27"
Error with UTF-8 escaped sequences:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error in object with multiple UTF-8 keys:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:22"
+int(4)
+string(31) "Syntax error near location 1:22"
Error in array with UTF-8 strings:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:18"
+int(4)
+string(31) "Syntax error near location 1:18"
Error in nested object with UTF-8:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:15"
-
+int(4)
+string(31) "Syntax error near location 1:15"
diff --git a/ext/json/tests/json_last_error_msg_error_location_004.phpt b/ext/json/tests/json_last_error_msg_error_location_004.phpt
index 165449600fb3..fcde3faa38a3 100644
--- a/ext/json/tests/json_last_error_msg_error_location_004.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_004.phpt
@@ -53,8 +53,8 @@ Testing error locations in deeply nested structures
Error in deeply nested object:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:31"
+int(4)
+string(31) "Syntax error near location 1:31"
Error in deeply nested array:
bool(true)
@@ -78,16 +78,15 @@ string(31) "Syntax error near location 1:21"
Error in complex structure:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:93"
+int(4)
+string(31) "Syntax error near location 1:93"
Error in array of objects:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:68"
+int(4)
+string(31) "Syntax error near location 1:68"
Error in object with array values:
bool(false)
int(2)
string(61) "State mismatch (invalid or malformed JSON) near location 1:82"
-
diff --git a/ext/json/tests/json_last_error_msg_error_location_005.phpt b/ext/json/tests/json_last_error_msg_error_location_005.phpt
index d12ce387e73e..ee1800eb8273 100644
--- a/ext/json/tests/json_last_error_msg_error_location_005.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_005.phpt
@@ -53,51 +53,50 @@ Testing error locations with UTF-16 surrogate pairs and escape sequences
Error after UTF-16 escaped emoji:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:11"
+int(4)
+string(31) "Syntax error near location 1:11"
Error after multiple UTF-16 pairs:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error with mixed UTF-8 and UTF-16:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:11"
+int(4)
+string(31) "Syntax error near location 1:11"
Error with UTF-16 in key:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:9"
+int(4)
+string(30) "Syntax error near location 1:9"
Error with multiple UTF-16 keys:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:22"
+int(4)
+string(31) "Syntax error near location 1:22"
Error with BMP characters:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error with supplementary plane:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:11"
+int(4)
+string(31) "Syntax error near location 1:11"
Error in array with UTF-16:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:12"
+int(4)
+string(31) "Syntax error near location 1:12"
Error in nested structure with UTF-16:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:18"
+int(4)
+string(31) "Syntax error near location 1:18"
Error with UTF-16 and control chars:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
-
+int(4)
+string(31) "Syntax error near location 1:10"
diff --git a/ext/json/tests/json_last_error_msg_error_location_006.phpt b/ext/json/tests/json_last_error_msg_error_location_006.phpt
index e6aab1af8f27..4a6c221c8f92 100644
--- a/ext/json/tests/json_last_error_msg_error_location_006.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_006.phpt
@@ -107,8 +107,8 @@ string(33) "Syntax error near location 1:1011"
Error with very long key:
bool(false)
-int(3)
-string(73) "Control character error, possibly incorrectly encoded near location 1:506"
+int(4)
+string(32) "Syntax error near location 1:506"
Error after empty object:
bool(false)
@@ -149,4 +149,3 @@ Error with mixed whitespace:
bool(false)
int(3)
string(71) "Control character error, possibly incorrectly encoded near location 3:2"
-
diff --git a/ext/json/tests/json_last_error_msg_error_location_007.phpt b/ext/json/tests/json_last_error_msg_error_location_007.phpt
index 0e24889bbbbe..168afc4dbfe2 100644
--- a/ext/json/tests/json_last_error_msg_error_location_007.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_007.phpt
@@ -118,8 +118,8 @@ string(30) "Syntax error near location 1:9"
Unclosed string:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:9"
+int(4)
+string(30) "Syntax error near location 1:9"
Invalid escape sequence:
bool(false)
@@ -175,4 +175,3 @@ Missing comma between object properties:
bool(false)
int(4)
string(30) "Syntax error near location 1:9"
-
diff --git a/ext/json/tests/json_last_error_msg_error_location_008.phpt b/ext/json/tests/json_last_error_msg_error_location_008.phpt
index 4d8a1012316b..60dd9513472b 100644
--- a/ext/json/tests/json_last_error_msg_error_location_008.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_008.phpt
@@ -82,101 +82,100 @@ Testing error locations with various UTF-8 multi-byte character widths
Error with 2-byte UTF-8 (Latin Extended):
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error with 2-byte UTF-8 (Greek):
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:14"
+int(4)
+string(31) "Syntax error near location 1:14"
Error with 2-byte UTF-8 (Cyrillic):
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:12"
+int(4)
+string(31) "Syntax error near location 1:12"
Error with 3-byte UTF-8 (Chinese):
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:8"
+int(4)
+string(30) "Syntax error near location 1:8"
Error with 3-byte UTF-8 (Japanese Hiragana):
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error with 3-byte UTF-8 (Japanese Katakana):
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error with 3-byte UTF-8 (Korean):
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:8"
+int(4)
+string(30) "Syntax error near location 1:8"
Error with 4-byte UTF-8 (Emoji faces):
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:11"
+int(4)
+string(31) "Syntax error near location 1:11"
Error with 4-byte UTF-8 (Emoji objects):
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:13"
+int(4)
+string(31) "Syntax error near location 1:13"
Error with 4-byte UTF-8 (Mathematical symbols):
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error with mixed 1-2-3 byte UTF-8:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:9"
+int(4)
+string(30) "Syntax error near location 1:9"
Error with mixed 2-3-4 byte UTF-8:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:9"
+int(4)
+string(30) "Syntax error near location 1:9"
Error with all byte widths:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:9"
+int(4)
+string(30) "Syntax error near location 1:9"
Error with UTF-8 key at start:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:7"
+int(4)
+string(30) "Syntax error near location 1:7"
Error with multiple UTF-8 keys:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:35"
+int(4)
+string(31) "Syntax error near location 1:35"
Error in array with mixed UTF-8:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:25"
+int(4)
+string(31) "Syntax error near location 1:25"
Error in nested structure with various UTF-8:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:22"
+int(4)
+string(31) "Syntax error near location 1:22"
Error with combining diacritical marks:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error with Hebrew:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
+int(4)
+string(31) "Syntax error near location 1:10"
Error with Arabic with diacritics:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:11"
-
+int(4)
+string(31) "Syntax error near location 1:11"
diff --git a/ext/json/tests/json_last_error_msg_error_location_009.phpt b/ext/json/tests/json_last_error_msg_error_location_009.phpt
index 406179693ef6..9d4403838f63 100644
--- a/ext/json/tests/json_last_error_msg_error_location_009.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_009.phpt
@@ -75,18 +75,18 @@ string(46) "Maximum stack depth exceeded near location 1:7"
Syntax error at deep nesting level:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:31"
+int(4)
+string(31) "Syntax error near location 1:31"
Syntax error in deep array:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:6"
+int(4)
+string(30) "Syntax error near location 1:6"
Error after valid deep structure:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:48"
+int(4)
+string(31) "Syntax error near location 1:48"
Error in middle of nested structure:
bool(false)
@@ -95,16 +95,15 @@ string(31) "Syntax error near location 1:29"
Error in array with nested objects:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:30"
+int(4)
+string(31) "Syntax error near location 1:30"
Error in deep UTF-8 structure:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:16"
+int(4)
+string(31) "Syntax error near location 1:16"
Valid deep structure within limit:
bool(true)
int(0)
string(8) "No error"
-
diff --git a/ext/json/tests/json_last_error_msg_error_location_010.phpt b/ext/json/tests/json_last_error_msg_error_location_010.phpt
index 108570205838..d187afff86cc 100644
--- a/ext/json/tests/json_last_error_msg_error_location_010.phpt
+++ b/ext/json/tests/json_last_error_msg_error_location_010.phpt
@@ -119,13 +119,13 @@ string(30) "Syntax error near location 4:2"
Error in string with spaces:
bool(false)
-int(3)
-string(71) "Control character error, possibly incorrectly encoded near location 1:9"
+int(4)
+string(30) "Syntax error near location 1:9"
Error with whitespace around colon:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:12"
+int(4)
+string(31) "Syntax error near location 1:12"
Error with whitespace around comma:
bool(true)
@@ -154,11 +154,10 @@ string(72) "Control character error, possibly incorrectly encoded near location
Error in compact JSON:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:22"
+int(4)
+string(31) "Syntax error near location 1:22"
Error with regular spaces:
bool(false)
-int(3)
-string(72) "Control character error, possibly incorrectly encoded near location 1:10"
-
+int(4)
+string(31) "Syntax error near location 1:10"
diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c
index 1fd044bbf950..8099c12089bb 100644
--- a/ext/lexbor/lexbor/url/url.c
+++ b/ext/lexbor/lexbor/url/url.c
@@ -159,7 +159,7 @@ static const uint8_t lxb_url_map[256] =
LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5b ([) */
LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5c (\) */
LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5d (]) */
- LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5e (^) */
+ LXB_URL_MAP_USERINFO|LXB_URL_MAP_PATH|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5e (^) */
LXB_URL_MAP_UNDEF, /* 0x5f (_) */
LXB_URL_MAP_PATH|LXB_URL_MAP_FRAGMENT|LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x60 (`) */
LXB_URL_MAP_UNDEF, /* 0x61 (a) */
@@ -1814,7 +1814,7 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url,
if (at_sign) {
if (begin == p || begin == p - 1) {
status = lxb_url_log_append(parser, p,
- LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS);
+ LXB_URL_ERROR_TYPE_HOST_MISSING);
if (status != LXB_STATUS_OK) {
lxb_url_parse_return(orig_data, buf, status);
}
diff --git a/ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch b/ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch
index e106b413c22c..f23ec0f5034d 100644
--- a/ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch
+++ b/ext/lexbor/patches/0001-Expose-line-and-column-information-for-use-in-PHP.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niels Dossche <7771979+nielsdos@users.noreply.github.com>
Date: Sat, 26 Aug 2023 15:08:59 +0200
-Subject: [PATCH 1/9] Expose line and column information for use in PHP
+Subject: [PATCH 01/11] Expose line and column information for use in PHP
---
source/lexbor/dom/interfaces/node.h | 2 ++
diff --git a/ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch b/ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch
index 9e83700198e1..8758c09a2e8f 100644
--- a/ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch
+++ b/ext/lexbor/patches/0002-Track-implied-added-nodes-for-options-use-in-PHP.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niels Dossche <7771979+nielsdos@users.noreply.github.com>
Date: Mon, 14 Aug 2023 20:18:51 +0200
-Subject: [PATCH 2/9] Track implied added nodes for options use in PHP
+Subject: [PATCH 02/11] Track implied added nodes for options use in PHP
---
source/lexbor/html/tree.h | 3 +++
diff --git a/ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch b/ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch
index 9ed36fda109d..56458a49deea 100644
--- a/ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch
+++ b/ext/lexbor/patches/0003-Patch-utilities-and-data-structure-to-be-able-to-gen.patch
@@ -1,8 +1,8 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niels Dossche <7771979+nielsdos@users.noreply.github.com>
Date: Thu, 24 Aug 2023 22:57:48 +0200
-Subject: [PATCH 3/9] Patch utilities and data structure to be able to generate
- smaller lookup tables
+Subject: [PATCH 03/11] Patch utilities and data structure to be able to
+ generate smaller lookup tables
Changed the generation script to check if everything fits in 32-bits.
And change the actual field types to 32-bits. This decreases the hash
diff --git a/ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch b/ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch
index 9e9fcaf8db7a..f4fe2050998d 100644
--- a/ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch
+++ b/ext/lexbor/patches/0004-Remove-unused-upper-case-tag-static-data.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niels Dossche <7771979+nielsdos@users.noreply.github.com>
Date: Wed, 29 Nov 2023 21:26:47 +0100
-Subject: [PATCH 4/9] Remove unused upper case tag static data
+Subject: [PATCH 04/11] Remove unused upper case tag static data
---
source/lexbor/tag/res.h | 2 ++
diff --git a/ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch b/ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch
index ce3448169395..f36a2d758181 100644
--- a/ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch
+++ b/ext/lexbor/patches/0005-Shrink-size-of-static-binary-search-tree.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niels Dossche <7771979+nielsdos@users.noreply.github.com>
Date: Wed, 29 Nov 2023 21:29:31 +0100
-Subject: [PATCH 5/9] Shrink size of static binary search tree
+Subject: [PATCH 05/11] Shrink size of static binary search tree
This also makes it more efficient on the data cache.
---
diff --git a/ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch b/ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch
index c8f485f34807..7c6e1beebf46 100644
--- a/ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch
+++ b/ext/lexbor/patches/0006-Patch-out-unused-CSS-style-code.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Niels Dossche <7771979+nielsdos@users.noreply.github.com>
Date: Sun, 7 Jan 2024 21:59:28 +0100
-Subject: [PATCH 6/9] Patch out unused CSS style code
+Subject: [PATCH 06/11] Patch out unused CSS style code
---
source/lexbor/css/rule.h | 2 ++
diff --git a/ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch b/ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch
index b56a1d1f06ab..50bdc2397ba5 100644
--- a/ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch
+++ b/ext/lexbor/patches/0007-Add-lxb_url_is_special-to-the-public-API-362.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?=
Date: Sun, 17 May 2026 22:17:14 +0200
-Subject: [PATCH 7/9] Add lxb_url_is_special() to the public API (#362)
+Subject: [PATCH 07/11] Add lxb_url_is_special() to the public API (#362)
As https://wiki.php.net/rfc/uri_followup#uri_type_detection relies on this information.
---
diff --git a/ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch b/ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch
index de66962e7cca..4218461c3bad 100644
--- a/ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch
+++ b/ext/lexbor/patches/0008-URL-fixed-setters-for-empty-hosts.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Alexander Borisov
Date: Fri, 26 Jun 2026 18:55:56 +0300
-Subject: [PATCH 8/9] URL: fixed setters for empty hosts.
+Subject: [PATCH 08/11] URL: fixed setters for empty hosts.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
diff --git a/ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch b/ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch
index 0fde094eefdf..91e78a899f44 100644
--- a/ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch
+++ b/ext/lexbor/patches/0009-URL-fixed-uninitialized-memory-in-the-path-buffer-gr.patch
@@ -1,7 +1,7 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Alexander Borisov
Date: Fri, 5 Jun 2026 22:13:32 +0300
-Subject: [PATCH 9/9] URL: fixed uninitialized memory in the path buffer
+Subject: [PATCH 09/11] URL: fixed uninitialized memory in the path buffer
growth.
When a path was long enough to outgrow the on-stack buffer, the first
diff --git a/ext/lexbor/patches/0010-Fix-parsing-for-URL-containing-empty-host-and-userin.patch b/ext/lexbor/patches/0010-Fix-parsing-for-URL-containing-empty-host-and-userin.patch
new file mode 100644
index 000000000000..9cbf3e0094ed
--- /dev/null
+++ b/ext/lexbor/patches/0010-Fix-parsing-for-URL-containing-empty-host-and-userin.patch
@@ -0,0 +1,48 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?=
+Date: Thu, 9 Jul 2026 21:51:05 +0200
+Subject: [PATCH 10/11] Fix parsing for URL containing empty host and userinfo
+
+The returned error code (LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS) apparently contradicts the specification:
+
+"If atSignSeen is true and buffer is the empty string, host-missing validation error, return failure."
+---
+ source/lexbor/url/url.c | 2 +-
+ test/files/lexbor/url/username_password.ton | 8 +++++++-
+ 2 files changed, 8 insertions(+), 2 deletions(-)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index 1fd044b..aed468a 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -1814,7 +1814,7 @@ again:
+ if (at_sign) {
+ if (begin == p || begin == p - 1) {
+ status = lxb_url_log_append(parser, p,
+- LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS);
++ LXB_URL_ERROR_TYPE_HOST_MISSING);
+ if (status != LXB_STATUS_OK) {
+ lxb_url_parse_return(orig_data, buf, status);
+ }
+diff --git a/test/files/lexbor/url/username_password.ton b/test/files/lexbor/url/username_password.ton
+index 28a27fd..5a5e63e 100644
+--- a/test/files/lexbor/url/username_password.ton
++++ b/test/files/lexbor/url/username_password.ton
+@@ -1,5 +1,5 @@
+ [
+- /* Test count: 11 */
++ /* Test count: 12 */
+ /* 1 */
+ {
+ "url": "https://user:password@lexbor.com",
+@@ -124,4 +124,10 @@
+ "failed": false,
+ "encoding": "utf-8"
+ }
++ /* 12 */
++ {
++ "url": "https://user:pass@",
++ "failed": true,
++ "encoding": "utf-8"
++ }
+ ]
diff --git a/ext/lexbor/patches/0011-Percent-encode-the-caret-in-the-path.patch b/ext/lexbor/patches/0011-Percent-encode-the-caret-in-the-path.patch
new file mode 100644
index 000000000000..2781fad5bcb9
--- /dev/null
+++ b/ext/lexbor/patches/0011-Percent-encode-the-caret-in-the-path.patch
@@ -0,0 +1,29 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?=
+Date: Fri, 10 Jul 2026 22:31:16 +0200
+Subject: [PATCH 11/11] Percent-encode the caret in the path
+
+The caret (^) is part of the path percent-encode set:
+
+"The path percent-encode set is a percent-encode set consisting of the query percent-encode set and U+003F (?), U+005E (^), U+0060 (`), U+007B ({), and U+007D (})."
+
+Until now, this character wasn't percent-encoded in the path likely due to a copy-paste error. This is mistake is fixed by adding LXB_URL_MAP_PATH to the lxb_url_map entry for the caret.
+
+Originally reported at https://github.com/php/php-src/issues/22628
+---
+ source/lexbor/url/url.c | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/source/lexbor/url/url.c b/source/lexbor/url/url.c
+index aed468a..8099c12 100644
+--- a/source/lexbor/url/url.c
++++ b/source/lexbor/url/url.c
+@@ -159,7 +159,7 @@ static const uint8_t lxb_url_map[256] =
+ LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5b ([) */
+ LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5c (\) */
+ LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5d (]) */
+- LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5e (^) */
++ LXB_URL_MAP_USERINFO|LXB_URL_MAP_PATH|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x5e (^) */
+ LXB_URL_MAP_UNDEF, /* 0x5f (_) */
+ LXB_URL_MAP_PATH|LXB_URL_MAP_FRAGMENT|LXB_URL_MAP_USERINFO|LXB_URL_MAP_COMPONENT|LXB_URL_MAP_X_WWW_FORM, /* 0x60 (`) */
+ LXB_URL_MAP_UNDEF, /* 0x61 (a) */
diff --git a/ext/lexbor/patches/update-lexbor.sh b/ext/lexbor/patches/update-lexbor.sh
index ae288285453d..92ef8aa58f66 100755
--- a/ext/lexbor/patches/update-lexbor.sh
+++ b/ext/lexbor/patches/update-lexbor.sh
@@ -15,7 +15,10 @@ git clone "$LEXBOR_REPO" "$LEXBOR_TMP_DIR"
(cd "$LEXBOR_TMP_DIR" && git checkout "$LEXBOR_REF")
# Apply patches
-mapfile -t patches < <(ls "$PATCHES_DIR"/*.patch)
+patches=()
+for f in "$PATCHES_DIR"/*.patch; do
+ [ -e "$f" ] && patches+=("$f")
+done
cd "$LEXBOR_TMP_DIR"
for patch in "${patches[@]}"; do
if ! git am -3 "$patch"; then
diff --git a/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt b/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt
index 2a1c554a79d0..7e0f660c980d 100644
--- a/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt
+++ b/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt
@@ -27,6 +27,46 @@ $serverCode = <<<'CODE'
CODE;
$serverCode = sprintf($serverCode, $certFile);
+/* Plain TCP proxy that forwards the server handshake flight in fragments, so the client's
+ * non-blocking handshake sees a partial TLS record and reports WANT_READ instead of completing
+ * in a single call (which can otherwise happen depending on timing). */
+$proxyCode = <<<'CODE'
+ $upstream = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 30, STREAM_CLIENT_CONNECT);
+ stream_set_blocking($upstream, false);
+
+ $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN;
+ $server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr, $flags);
+ phpt_notify_server_start($server);
+
+ $conn = stream_socket_accept($server);
+ stream_set_blocking($conn, false);
+
+ $read = [$upstream, $conn];
+ while (stream_select($read, $write, $except, 1)) {
+ foreach ($read as $fp) {
+ $data = stream_get_contents($fp);
+ if ($data === '') {
+ continue;
+ }
+ if ($fp === $conn) {
+ fwrite($upstream, $data);
+ } else {
+ /* Fragment server -> client to force a partial TLS record. */
+ foreach (str_split($data, (int) ceil(strlen($data) / 3)) as $part) {
+ fwrite($conn, $part);
+ usleep(50000);
+ }
+ }
+ }
+ if (feof($upstream) || feof($conn)) {
+ break;
+ }
+ $read = [$upstream, $conn];
+ }
+
+ phpt_wait();
+CODE;
+
/* Client connects over plain TCP, then completes the TLS handshake in non-blocking mode, using
* the reported crypto status to select the right direction to wait on. */
$clientCode = <<<'CODE'
@@ -73,7 +113,8 @@ $clientCode = <<<'CODE'
stream_set_blocking($client, true);
echo trim(fgets($client)), "\n";
- phpt_notify();
+ phpt_notify('server');
+ phpt_notify('proxy');
fclose($client);
CODE;
$clientCode = sprintf($clientCode, $peerName);
@@ -83,7 +124,10 @@ $certificateGenerator = new CertificateGenerator();
$certificateGenerator->saveNewCertAsFileWithKey($peerName, $certFile);
include 'ServerClientTestCase.inc';
-ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+ServerClientTestCase::getInstance()->run($clientCode, [
+ 'server' => $serverCode,
+ 'proxy' => $proxyCode,
+]);
?>
--CLEAN--
+#ifdef __APPLE__
+#include
+#endif
#define USE_POSIX_SPAWN
-/* The non-_np variant is in macOS 26 (and _np deprecated) */
-#ifdef HAVE_POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR
+/* The non-_np variant is in macOS 26 (and _np deprecated). On Apple, it has to be selected by the
+ * deployment target rather than the configure check: the link check passes whenever the SDK
+ * exports the symbol even if the running system is older, in which case the weakly linked
+ * reference resolves to NULL at runtime. */
+#if defined(__APPLE__) && MAC_OS_X_VERSION_MIN_REQUIRED >= 260000
+#define POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR posix_spawn_file_actions_addchdir
+#elif defined(__APPLE__)
+#define POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR posix_spawn_file_actions_addchdir_np
+#elif defined(HAVE_POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR)
#define POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR posix_spawn_file_actions_addchdir
#else
#define POSIX_SPAWN_FILE_ACTIONS_ADDCHDIR posix_spawn_file_actions_addchdir_np
diff --git a/ext/standard/tests/poll/poll.inc b/ext/standard/tests/poll/poll.inc
index a1ca01fc7d4a..ce001220bbac 100644
--- a/ext/standard/tests/poll/poll.inc
+++ b/ext/standard/tests/poll/poll.inc
@@ -106,7 +106,12 @@ function pt_write_sleep($stream, $data, $delay = 10000): int|false {
function pt_event_array_to_string(array $events): string {
$names = [];
foreach ($events as $event) {
- $names[] = $event->name;
+ if (is_array($event)) {
+ // Optional slot, rendered in brackets.
+ $names[] = '[' . pt_event_array_to_string($event) . ']';
+ } else {
+ $names[] = $event->name;
+ }
}
return empty($names) ? 'NONE' : implode('|', $names);
}
@@ -242,17 +247,48 @@ function pt_events_equal($actual, $expected): bool {
return false;
}
- if (count($actual) !== count($expected)) {
- return false;
+ // An expected item that is itself an array marks an optional slot: it may be
+ // absent, or filled by exactly one of the events it lists. Plain enums are
+ // required. This lets a backend accept e.g. Write with an optional HangUp
+ // that may not have propagated yet, without weakening the required events.
+ $required = [];
+ $optional_groups = [];
+ foreach ($expected as $item) {
+ if (is_array($item)) {
+ $optional_groups[] = array_map(fn($e) => $e->name, $item);
+ } else {
+ $required[] = $item->name;
+ }
}
- // Sort both arrays by event name for comparison
$actual_names = array_map(fn($e) => $e->name, $actual);
- $expected_names = array_map(fn($e) => $e->name, $expected);
- sort($actual_names);
- sort($expected_names);
- return $actual_names === $expected_names;
+ // Every required event must be present.
+ foreach ($required as $name) {
+ $idx = array_search($name, $actual_names, true);
+ if ($idx === false) {
+ return false;
+ }
+ unset($actual_names[$idx]);
+ }
+
+ // Each leftover actual event must be accounted for by a distinct optional
+ // slot that lists it.
+ foreach ($actual_names as $name) {
+ $matched = false;
+ foreach ($optional_groups as $gi => $group) {
+ if (in_array($name, $group, true)) {
+ unset($optional_groups[$gi]);
+ $matched = true;
+ break;
+ }
+ }
+ if (!$matched) {
+ return false;
+ }
+ }
+
+ return true;
}
function pt_resolve_backend_specific_value($backend_map, $current_backend) {
@@ -319,3 +355,68 @@ function pt_print_mismatched_events($actual_watchers, $expected_watchers, $match
echo $match_status . "\n";
}
}
+
+/*
+ * Self-test for the pt_* event-matching helpers.
+ *
+ * This is deliberately NOT a .phpt: the CI only runs .phpt files, never .inc,
+ * so this never executes there. It only runs when poll.inc is executed as the
+ * main script, which lets the (fiddly) optional-slot matching logic be verified
+ * by hand:
+ *
+ * sapi/cli/php ext/standard/tests/poll/poll.inc
+ */
+function pt_run_self_test(): void {
+ if (!enum_exists('Io\\Poll\\Event')) {
+ echo "Io\\Poll\\Event not available; build with poll support to run the self-test\n";
+ return;
+ }
+
+ $R = Io\Poll\Event::Read;
+ $W = Io\Poll\Event::Write;
+ $E = Io\Poll\Event::Error;
+ $H = Io\Poll\Event::HangUp;
+
+ $failures = 0;
+ $check = function (string $label, bool $cond) use (&$failures) {
+ if ($cond) {
+ echo "ok - $label\n";
+ } else {
+ echo "FAIL - $label\n";
+ ++$failures;
+ }
+ };
+
+ // Plain required sets are order independent and exact.
+ $check('exact set matches', pt_events_equal([$W, $H], [$H, $W]));
+ $check('missing required fails', !pt_events_equal([$W], [$W, $H]));
+ $check('extra unexpected fails', !pt_events_equal([$W, $H], [$W]));
+
+ // Optional slot: [$W, [$H]] means Write required, HangUp allowed but not
+ // required.
+ $check('optional present matches', pt_events_equal([$W, $H], [$W, [$H]]));
+ $check('optional absent matches', pt_events_equal([$W], [$W, [$H]]));
+ $check('optional cannot satisfy a required event', !pt_events_equal([$H], [$W, [$H]]));
+ $check('unlisted extra with optional fails', !pt_events_equal([$W, $E], [$W, [$H]]));
+
+ // A single optional slot accepts at most one of its listed events; separate
+ // slots can each accept one.
+ $check('optional picks one of group', pt_events_equal([$W, $H], [$W, [$H, $E]]));
+ $check('optional group may stay empty', pt_events_equal([$W], [$W, [$H, $E]]));
+ $check('single slot rejects two', !pt_events_equal([$W, $H, $E], [$W, [$H, $E]]));
+ $check('two slots accept two', pt_events_equal([$W, $H, $E], [$W, [$H], [$E]]));
+
+ // Rendering.
+ $check('renders plain set', pt_event_array_to_string([$W, $H]) === 'Write|HangUp');
+ $check('renders optional slot', pt_event_array_to_string([$W, [$H]]) === 'Write|[HangUp]');
+ $check('renders empty as NONE', pt_event_array_to_string([]) === 'NONE');
+
+ echo $failures === 0
+ ? "\nAll self-tests passed\n"
+ : "\n$failures self-test(s) FAILED\n";
+}
+
+if (isset($_SERVER['SCRIPT_FILENAME'])
+ && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
+ pt_run_self_test();
+}
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt
index 4edd1619d309..3909dea58637 100644
--- a/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_close.phpt
@@ -17,7 +17,11 @@ pt_expect_events($poll_ctx->wait(0, 100000), [
[
'events' => [
'default' => [Io\Poll\Event::Write, Io\Poll\Event::Error, Io\Poll\Event::HangUp],
- 'Kqueue|EventPorts' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
+ 'Kqueue' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
+ // On Solaris event ports the peer close may not be folded into a
+ // POLLHUP in the same snapshot as the POLLOUT readiness, so HangUp
+ // is optional here.
+ 'EventPorts' => [Io\Poll\Event::Write, [Io\Poll\Event::HangUp]],
],
'data' => 'socket2_data'
]
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt
new file mode 100644
index 000000000000..d78d3f4fde35
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_max_events.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Poll stream - socket read/write events with maxEvents equal to the FD count
+--FILE--
+ [Io\Poll\Event::Read, Io\Poll\Event::Write],
+ 'data' => "sock$i",
+ 'read' => "test $i data",
+ ];
+}
+
+pt_expect_events($poll_ctx->wait(0, 100000, 8), $expected);
+
+// All read data was drained above, so only write events remain
+$expected = [];
+for ($i = 0; $i < 4; $i++) {
+ $expected[] = ['events' => [Io\Poll\Event::Write], 'data' => "sock$i"];
+}
+pt_expect_events($poll_ctx->wait(0, 100000, 8), $expected);
+
+?>
+--EXPECT--
+Events matched - count: 4
+Events matched - count: 4
diff --git a/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt b/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt
index 57fc335c26c4..8bc8ea7fcc3d 100644
--- a/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt
+++ b/ext/standard/tests/poll/poll_stream_sock_rw_multi_level.phpt
@@ -43,7 +43,16 @@ pt_expect_events($poll_ctx->wait(0, 100000), [
fclose($socket1r);
pt_expect_events($poll_ctx->wait(0, 100000), [
- ['events' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp], 'data' => 'socket2_data']
+ [
+ 'events' => [
+ 'default' => [Io\Poll\Event::Write, Io\Poll\Event::HangUp],
+ // On Solaris event ports the peer close may not be folded into a
+ // POLLHUP in the same snapshot as the POLLOUT readiness, so HangUp
+ // is optional here.
+ 'EventPorts' => [Io\Poll\Event::Write, [Io\Poll\Event::HangUp]],
+ ],
+ 'data' => 'socket2_data'
+ ]
], $poll_ctx);
fclose($socket1w);
diff --git a/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt b/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt
new file mode 100644
index 000000000000..f927e73ed4d3
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_read_max_events.phpt
@@ -0,0 +1,45 @@
+--TEST--
+Poll stream - TCP read with maxEvents smaller than the number of ready FDs
+--FILE--
+wait(0, 100000, 4);
+ echo "Round $round count: " . count($watchers) . "\n";
+ foreach ($watchers as $watcher) {
+ $data = $watcher->getData();
+ if (isset($seen[$data])) {
+ echo "Duplicate event for $data\n";
+ }
+ $seen[$data] = true;
+ // Drain so level triggering does not re-report the FD
+ fread($watcher->getHandle()->getStream(), 1024);
+ }
+}
+
+ksort($seen);
+echo "Seen: " . implode(',', array_keys($seen)) . "\n";
+
+pt_expect_events($poll_ctx->wait(0), []);
+
+?>
+--EXPECT--
+Round 1 count: 4
+Round 2 count: 4
+Seen: server0,server1,server2,server3,server4,server5,server6,server7
+Events matched - count: 0
diff --git a/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt b/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt
new file mode 100644
index 000000000000..d7a2e48be6f4
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_tcp_read_one_shot_max_events.phpt
@@ -0,0 +1,46 @@
+--TEST--
+Poll stream - TCP read oneshot with maxEvents smaller than the number of ready FDs
+--FILE--
+wait(0, 100000, 4);
+ echo "Round $round count: " . count($watchers) . "\n";
+ foreach ($watchers as $watcher) {
+ $data = $watcher->getData();
+ if (isset($seen[$data])) {
+ echo "Duplicate event for $data\n";
+ }
+ $seen[$data] = true;
+ }
+}
+
+ksort($seen);
+echo "Seen: " . implode(',', array_keys($seen)) . "\n";
+
+// Every oneshot watcher fired once, so new data must not be reported
+pt_write_sleep($clients[0], "more data");
+pt_expect_events($poll_ctx->wait(0, 100000), []);
+
+?>
+--EXPECT--
+Round 1 count: 4
+Round 2 count: 4
+Seen: server0,server1,server2,server3,server4,server5,server6,server7
+Events matched - count: 0
diff --git a/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt b/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt
index 8f34b6db9de6..a871b6614a75 100644
--- a/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt
+++ b/ext/uri/tests/whatwg/modification/path_success_auto_encoded.phpt
@@ -13,5 +13,5 @@ var_dump($url2->toAsciiString());
?>
--EXPECT--
string(1) "/"
-string(8) "/p^th%23"
-string(27) "https://example.com/p^th%23"
+string(10) "/p%5Eth%23"
+string(29) "https://example.com/p%5Eth%23"
diff --git a/ext/uri/tests/whatwg/parsing/host_error_empty2.phpt b/ext/uri/tests/whatwg/parsing/host_error_empty2.phpt
index 934185455641..726241674fae 100644
--- a/ext/uri/tests/whatwg/parsing/host_error_empty2.phpt
+++ b/ext/uri/tests/whatwg/parsing/host_error_empty2.phpt
@@ -11,4 +11,4 @@ try {
?>
--EXPECT--
-Uri\WhatWg\InvalidUrlException: The specified URI is malformed
+Uri\WhatWg\InvalidUrlException: The specified URI is malformed (HostMissing)
diff --git a/ext/uri/tests/whatwg/parsing/path_success_percent_encode_set2.phpt b/ext/uri/tests/whatwg/parsing/path_success_percent_encode_set2.phpt
index 9419c817fbbd..dd82beeff101 100644
--- a/ext/uri/tests/whatwg/parsing/path_success_percent_encode_set2.phpt
+++ b/ext/uri/tests/whatwg/parsing/path_success_percent_encode_set2.phpt
@@ -22,10 +22,10 @@ object(Uri\WhatWg\Url)#%d (%d) {
["port"]=>
NULL
["path"]=>
- string(28) "/foo%22/%3Cbar%3E/^%7Bbaz%7D"
+ string(30) "/foo%22/%3Cbar%3E/%5E%7Bbaz%7D"
["query"]=>
NULL
["fragment"]=>
NULL
}
-string(47) "https://example.com/foo%22/%3Cbar%3E/^%7Bbaz%7D"
+string(49) "https://example.com/foo%22/%3Cbar%3E/%5E%7Bbaz%7D"
diff --git a/main/poll/poll_backend_kqueue.c b/main/poll/poll_backend_kqueue.c
index 00ba7abc9ee6..19ff0ad6d228 100644
--- a/main/poll/poll_backend_kqueue.c
+++ b/main/poll/poll_backend_kqueue.c
@@ -303,149 +303,179 @@ static int kqueue_backend_wait(
{
kqueue_backend_data_t *backend_data = (kqueue_backend_data_t *) ctx->backend_data;
- /* For raw events, we need capacity for max_events.
- * For grouped events, kqueue can return up to 2 events per FD, so we need 2x capacity. */
- int required_capacity = ctx->raw_events ? max_events : (max_events * 2);
- if (required_capacity > backend_data->events_capacity) {
+ /* Cap every kevent() request at the entries we can still deliver, like epoll's maxevents.
+ * Unretrieved events stay pending in the kqueue (EV_ONESHOT deletes and EV_CLEAR resets only
+ * at retrieval), so nothing is lost and the caller's buffer cannot overflow. */
+ if (max_events > backend_data->events_capacity) {
struct kevent *new_events = php_poll_realloc(
- backend_data->events, required_capacity * sizeof(struct kevent), ctx->persistent);
+ backend_data->events, max_events * sizeof(struct kevent), ctx->persistent);
if (!new_events) {
php_poll_set_error(ctx, PHP_POLL_ERR_NOMEM);
return -1;
}
backend_data->events = new_events;
- backend_data->events_capacity = required_capacity;
+ backend_data->events_capacity = max_events;
}
- int nfds = kevent(
- backend_data->kqueue_fd, NULL, 0, backend_data->events, required_capacity, timeout);
+ if (ctx->raw_events) {
+ /* Raw events mode - direct 1:1 mapping, no grouping */
+ int nfds = kevent(
+ backend_data->kqueue_fd, NULL, 0, backend_data->events, max_events, timeout);
- if (nfds < 0) {
- php_poll_set_current_errno_error(ctx);
- return -1;
+ if (nfds < 0) {
+ php_poll_set_current_errno_error(ctx);
+ return -1;
+ }
+
+ for (int i = 0; i < nfds; i++) {
+ events[i].fd = (int) backend_data->events[i].ident;
+ events[i].events = 0;
+ events[i].revents = 0;
+ events[i].data = backend_data->events[i].udata;
+
+ /* Convert kqueue filter to poll event */
+ if (backend_data->events[i].filter == EVFILT_READ) {
+ events[i].revents |= PHP_POLL_READ;
+ } else if (backend_data->events[i].filter == EVFILT_WRITE) {
+ events[i].revents |= PHP_POLL_WRITE;
+ }
+
+ /* Convert kqueue flags to poll events */
+ if (backend_data->events[i].flags & EV_EOF) {
+ events[i].revents |= PHP_POLL_HUP;
+ }
+ if (backend_data->events[i].flags & EV_ERROR) {
+ events[i].revents |= PHP_POLL_ERROR;
+ }
+ }
+
+ return nfds;
}
- if (nfds > 0) {
- if (ctx->raw_events) {
- /* Raw events mode - direct 1:1 mapping, no grouping */
- for (int i = 0; i < nfds && i < max_events; i++) {
- events[i].fd = (int) backend_data->events[i].ident;
- events[i].events = 0;
- events[i].revents = 0;
- events[i].data = backend_data->events[i].udata;
-
- /* Convert kqueue filter to poll event */
- if (backend_data->events[i].filter == EVFILT_READ) {
- events[i].revents |= PHP_POLL_READ;
- } else if (backend_data->events[i].filter == EVFILT_WRITE) {
- events[i].revents |= PHP_POLL_WRITE;
- }
+ /* Grouped events mode with improved oneshot tracking.
+ *
+ * kqueue reports one event per (FD, filter) pair, so a READ+WRITE pair merges into a single
+ * entry and one round can fill fewer than max_events entries while more events are pending.
+ * Top up with zero-timeout rounds, each requesting only the remaining free entries. Both
+ * FreeBSD and XNU re-enqueue delivered level-triggered events at the tail of the active queue,
+ * so top-up rounds see not-yet-delivered events first, matching epoll's round-robin fill. */
+ int unique_events = 0, garbage_events = 0;
+ const struct timespec zero_timeout = {0, 0};
+ const struct timespec *round_timeout = timeout;
+
+ while (true) {
+ int slots = max_events - unique_events;
+ int nfds = kevent(
+ backend_data->kqueue_fd, NULL, 0, backend_data->events, slots, round_timeout);
+
+ if (nfds < 0) {
+ if (unique_events > 0) {
+ /* Deliver what was already gathered */
+ break;
+ }
+ php_poll_set_current_errno_error(ctx);
+ return -1;
+ }
- /* Convert kqueue flags to poll events */
- if (backend_data->events[i].flags & EV_EOF) {
- events[i].revents |= PHP_POLL_HUP;
- }
- if (backend_data->events[i].flags & EV_ERROR) {
- events[i].revents |= PHP_POLL_ERROR;
- }
+ int new_unique_events = 0;
+
+ for (int i = 0; i < nfds; i++) {
+ int fd = (int) backend_data->events[i].ident;
+ uint32_t revents = 0;
+ void *data = backend_data->events[i].udata;
+ bool is_oneshot = (backend_data->events[i].flags & EV_ONESHOT) != 0;
+
+ /* Convert this event */
+ if (backend_data->events[i].filter == EVFILT_READ) {
+ revents |= PHP_POLL_READ;
+ } else if (backend_data->events[i].filter == EVFILT_WRITE) {
+ revents |= PHP_POLL_WRITE;
}
- return nfds > max_events ? max_events : nfds;
- } else {
- /* Grouped events mode with improved oneshot tracking */
- int unique_events = 0, garbage_events = 0, fd;
-
- for (int i = 0; i < nfds; i++) {
- fd = (int) backend_data->events[i].ident;
- uint32_t revents = 0;
- void *data = backend_data->events[i].udata;
- bool is_oneshot = (backend_data->events[i].flags & EV_ONESHOT) != 0;
-
- /* Convert this event */
- if (backend_data->events[i].filter == EVFILT_READ) {
- revents |= PHP_POLL_READ;
- } else if (backend_data->events[i].filter == EVFILT_WRITE) {
- revents |= PHP_POLL_WRITE;
- }
- if (backend_data->events[i].flags & EV_EOF) {
- revents |= PHP_POLL_HUP;
- }
- if (backend_data->events[i].flags & EV_ERROR) {
- revents |= PHP_POLL_ERROR;
- }
+ if (backend_data->events[i].flags & EV_EOF) {
+ revents |= PHP_POLL_HUP;
+ }
+ if (backend_data->events[i].flags & EV_ERROR) {
+ revents |= PHP_POLL_ERROR;
+ }
- /* Look for existing event for this FD */
- bool found = false;
- for (int j = 0; j < unique_events; j++) {
- if (events[j].fd == fd) {
- /* Combine with existing event */
- events[j].revents |= revents;
- found = true;
- break;
- }
+ /* Look for existing event for this FD */
+ bool found = false;
+ for (int j = 0; j < unique_events; j++) {
+ if (events[j].fd == fd) {
+ /* Combine with existing event */
+ events[j].revents |= revents;
+ found = true;
+ break;
}
+ }
- if (!found) {
- /* New FD, create new event */
- ZEND_ASSERT(unique_events < max_events);
- events[unique_events].fd = fd;
- events[unique_events].events = 0;
- events[unique_events].revents = revents;
- events[unique_events].data = data;
- unique_events++;
-
- /* Handle oneshot tracking */
- if (is_oneshot) {
- zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd);
- if (tracking && (Z_LVAL_P(tracking) & KQUEUE_FD_ONESHOT_COMPLETE)) {
- /* Mark which filter fired for garbage collection */
- zend_long flags = Z_LVAL_P(tracking);
- flags &= ~KQUEUE_FD_ONESHOT_COMPLETE; /* Clear complete flag */
- if (revents & PHP_POLL_READ) {
- flags |= KQUEUE_FD_GARBAGE_READ; /* Need to clean write */
- }
- if (revents & PHP_POLL_WRITE) {
- flags |= KQUEUE_FD_GARBAGE_WRITE; /* Need to clean read */
- }
- ZVAL_LONG(tracking, flags);
- backend_data->fd_count--;
- garbage_events++;
- }
- }
- } else if (is_oneshot) {
- /* Second filter for same FD fired - clear garbage flags */
+ if (!found) {
+ /* New FD, create new event */
+ ZEND_ASSERT(unique_events < max_events);
+ events[unique_events].fd = fd;
+ events[unique_events].events = 0;
+ events[unique_events].revents = revents;
+ events[unique_events].data = data;
+ unique_events++;
+ new_unique_events++;
+
+ /* Handle oneshot tracking */
+ if (is_oneshot) {
zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd);
- if (tracking) {
- /* Remove FD from tracking as it gets deleted from kqueue as well */
- zend_hash_index_del(backend_data->fd_tracking, fd);
- garbage_events--;
+ if (tracking && (Z_LVAL_P(tracking) & KQUEUE_FD_ONESHOT_COMPLETE)) {
+ /* Mark which filter fired for garbage collection */
+ zend_long flags = Z_LVAL_P(tracking);
+ flags &= ~KQUEUE_FD_ONESHOT_COMPLETE; /* Clear complete flag */
+ if (revents & PHP_POLL_READ) {
+ flags |= KQUEUE_FD_GARBAGE_READ; /* Need to clean write */
+ }
+ if (revents & PHP_POLL_WRITE) {
+ flags |= KQUEUE_FD_GARBAGE_WRITE; /* Need to clean read */
+ }
+ ZVAL_LONG(tracking, flags);
+ backend_data->fd_count--;
+ garbage_events++;
}
}
- }
-
- if (garbage_events > 0) {
- /* Clean up orphaned filters from complete oneshot FDs */
- struct kevent cleanup_change;
- ZEND_HASH_FOREACH_NUM_KEY_VAL(backend_data->fd_tracking, zend_ulong fd_key, zval *tracking)
- {
- zend_long flags = Z_LVAL_P(tracking);
- if (flags & KQUEUE_FD_HAS_GARBAGE) {
- int filter = (flags & KQUEUE_FD_GARBAGE_READ) ? EVFILT_WRITE : EVFILT_READ;
- EV_SET(&cleanup_change, fd_key, filter, EV_DELETE, 0, 0, NULL);
- kevent(backend_data->kqueue_fd, &cleanup_change, 1, NULL, 0, NULL);
-
- /* Remove FD from tracking after cleanup */
- zend_hash_index_del(backend_data->fd_tracking, fd_key);
- }
+ } else if (is_oneshot) {
+ /* Second filter for same FD fired - clear garbage flags */
+ zval *tracking = zend_hash_index_find(backend_data->fd_tracking, fd);
+ if (tracking) {
+ /* Remove FD from tracking as it gets deleted from kqueue as well */
+ zend_hash_index_del(backend_data->fd_tracking, fd);
+ garbage_events--;
}
- ZEND_HASH_FOREACH_END();
}
+ }
- return unique_events;
+ /* Stop on a partial round (drained or timed out), a full buffer, or a full round with no
+ * new FDs (kernel re-reported level-triggered events already merged above - don't spin) */
+ if (nfds < slots || unique_events == max_events || new_unique_events == 0) {
+ break;
}
+ round_timeout = &zero_timeout;
}
- return nfds;
+ if (garbage_events > 0) {
+ /* Clean up orphaned filters from complete oneshot FDs */
+ struct kevent cleanup_change;
+ ZEND_HASH_FOREACH_NUM_KEY_VAL(backend_data->fd_tracking, zend_ulong fd_key, zval *tracking)
+ {
+ zend_long flags = Z_LVAL_P(tracking);
+ if (flags & KQUEUE_FD_HAS_GARBAGE) {
+ int filter = (flags & KQUEUE_FD_GARBAGE_READ) ? EVFILT_WRITE : EVFILT_READ;
+ EV_SET(&cleanup_change, fd_key, filter, EV_DELETE, 0, 0, NULL);
+ kevent(backend_data->kqueue_fd, &cleanup_change, 1, NULL, 0, NULL);
+
+ /* Remove FD from tracking after cleanup */
+ zend_hash_index_del(backend_data->fd_tracking, fd_key);
+ }
+ }
+ ZEND_HASH_FOREACH_END();
+ }
+
+ return unique_events;
}
static bool kqueue_backend_is_available(void)
@@ -466,15 +496,11 @@ static int kqueue_backend_get_suitable_max_events(php_poll_ctx *ctx)
return -1;
}
- if (ctx->raw_events) {
- /* For raw events, return the total number of filters */
- int active_filters = backend_data->filter_count;
- return active_filters == 0 ? 1 : active_filters;
- } else {
- /* For grouped events, return the number of unique FDs */
- int active_fds = backend_data->fd_count;
- return active_fds == 0 ? 1 : active_fds;
- }
+ /* kqueue reports one event per filter even in grouped mode (events are merged into
+ * per-FD entries afterwards) and the wait request is capped at max_events, so size
+ * for the total filter count to allow retrieving all pending events in one call. */
+ int active_filters = backend_data->filter_count;
+ return active_filters == 0 ? 1 : active_filters;
}
const php_poll_backend_ops php_poll_backend_kqueue_ops = {