From e63e2b7cc8ba7406cb02ec12a0e978579b52b305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Mon, 10 Aug 2026 08:40:30 +0200 Subject: [PATCH 1/8] Fix typed reference property writes during Uri\Rfc3986\UriBuilder::reset() (#23144) Originally found in https://github.com/php/php-src/pull/22268#discussion_r3705136377 --- ext/uri/php_uri.c | 46 +++++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/ext/uri/php_uri.c b/ext/uri/php_uri.c index 74a559fd591c..6ede828b69fe 100644 --- a/ext/uri/php_uri.c +++ b/ext/uri/php_uri.c @@ -66,13 +66,14 @@ static zend_always_inline zval *php_uri_deref(zval *zv) return zv; } -#define Z_RFC3986_URI_PROP_SCHEME_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 0)) -#define Z_RFC3986_URI_PROP_USERINFO_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 1)) -#define Z_RFC3986_URI_PROP_HOST_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 2)) -#define Z_RFC3986_URI_PROP_PORT_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 3)) -#define Z_RFC3986_URI_PROP_PATH_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 4)) -#define Z_RFC3986_URI_PROP_QUERY_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 5)) -#define Z_RFC3986_URI_PROP_FRAGMENT_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 6)) +#define Z_RFC3986_URI_PROP_SCHEME_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 0)) +#define Z_RFC3986_URI_PROP_USERINFO_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 1)) +#define Z_RFC3986_URI_PROP_HOST_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 2)) +#define Z_RFC3986_URI_PROP_PORT_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 3)) +#define Z_RFC3986_URI_PROP_PATH_P(zv) OBJ_PROP_NUM(Z_OBJ_P(zv), 4) +#define Z_RFC3986_URI_PROP_PATH_DEREF_P(zv) php_uri_deref(Z_RFC3986_URI_PROP_PATH_P(zv)) +#define Z_RFC3986_URI_PROP_QUERY_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 5)) +#define Z_RFC3986_URI_PROP_FRAGMENT_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 6)) static HashTable *uri_get_debug_properties(php_uri_object *object) { @@ -1069,14 +1070,17 @@ PHP_METHOD(Uri_Rfc3986_UriBuilder, reset) { ZEND_PARSE_PARAMETERS_NONE(); - convert_to_null(Z_RFC3986_URI_PROP_SCHEME_P(ZEND_THIS)); - convert_to_null(Z_RFC3986_URI_PROP_USERINFO_P(ZEND_THIS)); - convert_to_null(Z_RFC3986_URI_PROP_HOST_P(ZEND_THIS)); - convert_to_null(Z_RFC3986_URI_PROP_PORT_P(ZEND_THIS)); - zval_ptr_dtor(Z_RFC3986_URI_PROP_PATH_P(ZEND_THIS)); + zend_object *object = Z_OBJ_P(ZEND_THIS); + zval *property = object->properties_table; + const zval *end = property + object->ce->default_properties_count; + + while (property != end) { + zend_object_dtor_property(object, property); + ZVAL_NULL(property); + property++; + } + ZVAL_EMPTY_STRING(Z_RFC3986_URI_PROP_PATH_P(ZEND_THIS)); - convert_to_null(Z_RFC3986_URI_PROP_QUERY_P(ZEND_THIS)); - convert_to_null(Z_RFC3986_URI_PROP_FRAGMENT_P(ZEND_THIS)); RETVAL_COPY(ZEND_THIS); } @@ -1219,13 +1223,13 @@ PHP_METHOD(Uri_Rfc3986_UriBuilder, build) Z_PARAM_OBJECT_OF_CLASS_OR_NULL(base_url, php_uri_ce_rfc3986_uri) ZEND_PARSE_PARAMETERS_END(); - const zval *scheme = Z_RFC3986_URI_PROP_SCHEME_P(ZEND_THIS); - const zval *userinfo = Z_RFC3986_URI_PROP_USERINFO_P(ZEND_THIS); - const zval *host = Z_RFC3986_URI_PROP_HOST_P(ZEND_THIS); - const zval *port = Z_RFC3986_URI_PROP_PORT_P(ZEND_THIS); - const zval *path = Z_RFC3986_URI_PROP_PATH_P(ZEND_THIS); - const zval *query = Z_RFC3986_URI_PROP_QUERY_P(ZEND_THIS); - const zval *fragment = Z_RFC3986_URI_PROP_FRAGMENT_P(ZEND_THIS); + const zval *scheme = Z_RFC3986_URI_PROP_SCHEME_DEREF_P(ZEND_THIS); + const zval *userinfo = Z_RFC3986_URI_PROP_USERINFO_DEREF_P(ZEND_THIS); + const zval *host = Z_RFC3986_URI_PROP_HOST_DEREF_P(ZEND_THIS); + const zval *port = Z_RFC3986_URI_PROP_PORT_DEREF_P(ZEND_THIS); + const zval *path = Z_RFC3986_URI_PROP_PATH_DEREF_P(ZEND_THIS); + const zval *query = Z_RFC3986_URI_PROP_QUERY_DEREF_P(ZEND_THIS); + const zval *fragment = Z_RFC3986_URI_PROP_FRAGMENT_DEREF_P(ZEND_THIS); php_uri_parser_rfc3986_uris *base_uris = NULL; if (base_url != NULL) { From 66ceedae95d9bfc3d286aa86f12ae6c92077f719 Mon Sep 17 00:00:00 2001 From: Weilin Du Date: Mon, 10 Aug 2026 15:58:31 +0800 Subject: [PATCH 2/8] ext/intl: Fix stale intl error state in IntlNumberRangeFormatter (#23191) IntlNumberRangeFormatter::createFromSkeleton() and IntlNumberRangeFormatter::format() did not reset intl error state. This commit fix it. I know this is yet another "error state" fixes. Unfortunately we couldn't use the function macro added before because these are methods, and we can only reset the error state manually so far. I personally hate the error state design. I think we should throw exceptions instead. But considering BC breaks... this is just an idea in the void. IntlNumberRangeFormatter is added in 8.6 so this is the correct branch. --- NEWS | 2 + .../rangeformatter/rangeformatter_class.cpp | 9 +++- .../rangeformatter_create_error_reset.phpt | 54 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 ext/intl/tests/rangeformatter/rangeformatter_create_error_reset.phpt diff --git a/NEWS b/NEWS index b091ea276142..8ea376a2c247 100644 --- a/NEWS +++ b/NEWS @@ -46,6 +46,8 @@ PHP NEWS string. (Weilin Du) . Fixed IntlListFormatter::__construct() leaving stale global error state after successful calls. (Weilin Du) + . Fixed IntlNumberRangeFormatter leaving stale global error state after + successful createFromSkeleton() and format() calls. (Weilin Du) . Implemented GH-20255 (Add a predefined calendar constant in IntlDateFormatter for the proleptic gregorian calendar). (David Carlier) . Added SpoofChecker::areBidiConfusable(). (David Carlier) diff --git a/ext/intl/rangeformatter/rangeformatter_class.cpp b/ext/intl/rangeformatter/rangeformatter_class.cpp index 2dbb60c5b639..95acfccd2452 100644 --- a/ext/intl/rangeformatter/rangeformatter_class.cpp +++ b/ext/intl/rangeformatter/rangeformatter_class.cpp @@ -88,6 +88,8 @@ U_CFUNC PHP_METHOD(IntlNumberRangeFormatter, createFromSkeleton) zend_long collapse; zend_long identityFallback; + intl_error_reset(NULL); + ZEND_PARSE_PARAMETERS_START(4,4) Z_PARAM_STRING(skeleton, skeleton_len) Z_PARAM_STRING(locale, locale_len) @@ -158,7 +160,10 @@ U_CFUNC PHP_METHOD(IntlNumberRangeFormatter, format) zval *start; zval *end; + intl_error_reset(NULL); + IntlNumberRangeFormatter_object* obj = Z_INTL_RANGEFORMATTER_P(ZEND_THIS); + intl_error_reset(RANGEFORMATTER_ERROR_P(obj)); ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_NUMBER(start) @@ -179,13 +184,13 @@ U_CFUNC PHP_METHOD(IntlNumberRangeFormatter, format) INTL_G(error_level) = 0; if (U_FAILURE(error)) { - intl_error_set(NULL, error, "Failed to format number range"); + intl_errors_set(RANGEFORMATTER_ERROR_P(obj), error, "Failed to format number range"); } zend_string *ret = intl_charFromString(result, &error); if (U_FAILURE(error)) { - intl_error_set(NULL, error, "Failed to convert result to UTF-8"); + intl_errors_set(RANGEFORMATTER_ERROR_P(obj), error, "Failed to convert result to UTF-8"); } INTL_G(use_exceptions) = old_use_exception; diff --git a/ext/intl/tests/rangeformatter/rangeformatter_create_error_reset.phpt b/ext/intl/tests/rangeformatter/rangeformatter_create_error_reset.phpt new file mode 100644 index 000000000000..fb19da87d58d --- /dev/null +++ b/ext/intl/tests/rangeformatter/rangeformatter_create_error_reset.phpt @@ -0,0 +1,54 @@ +--TEST-- +IntlNumberRangeFormatter resets stale errors +--EXTENSIONS-- +intl +--SKIPIF-- + +--FILE-- +format(1, 2); + +var_dump(intl_get_error_code()); +var_dump(intl_get_error_message()); +?> +--EXPECT-- +bool(true) +int(0) +string(12) "U_ZERO_ERROR" +int(0) +string(12) "U_ZERO_ERROR" From 5206ff33ca21be3bcbe5e402fcb856978ff457df Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Mon, 10 Aug 2026 10:04:23 +0200 Subject: [PATCH 3/8] Zend: compile time assert on Bucket size (#23079) In zend_compile.c, flags are stored in the lower bits of the Bucket address. If Bucket is aligned to 8 bytes, the lower three bits are always zero and this gives no problems. If the Bucket is not aligned, this results in non-obvious errors because the memory address and the flags overlap. This is difficult to debug when it happens, so add this assertion to make it more obvious what is wrong. The flags are ZEND_BIND_REF, ZEND_BIND_IMPLICIT, ZEND_BIND_EXPLICIT. Related to GH-19079 --- Zend/zend_compile.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index a2f126fb101d..882b1bf990bf 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -5916,6 +5916,8 @@ static void zend_compile_static_var_common(zend_string *var_name, zval *value, u opline = zend_emit_op(NULL, ZEND_BIND_STATIC, NULL, NULL); opline->op1_type = IS_CV; opline->op1.var = lookup_cv(var_name); + + ZEND_STATIC_ASSERT(sizeof(Bucket) % 8 == 0, "Bucket size not compatible with storing flags in lower three bits"); opline->extended_value = (uint32_t)((char*)value - (char*)CG(active_op_array)->static_variables->arData) | mode; } /* }}} */ From 77170ee6ee26e4fc8078629e6e8720202769ca45 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Mon, 10 Aug 2026 13:07:44 +0500 Subject: [PATCH 4/8] Add a stack limit check in zend_hash_compare() (#23090) Comparing two deeply nested arrays recurses through zend_compare_arrays -> zend_compare_symbol_tables -> zend_hash_compare once per nesting level, and nothing bounds that recursion. zend_hash_compare() only guards against cycles, so a non-cyclic array a few tens of thousands of levels deep runs the C stack out and the process dies with a segfault. === crashes the same way through zend_is_identical(). Both now check the stack limit before descending and throw an Error instead, the same way zend_std_compare_objects() already handles the object case. Fixes GH-23088 --- NEWS | 4 ++++ Zend/tests/gh18572.phpt | 2 +- Zend/tests/gh23088.phpt | 40 ++++++++++++++++++++++++++++++++++++++++ Zend/zend_hash.c | 7 +++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 Zend/tests/gh23088.phpt diff --git a/NEWS b/NEWS index 05e3a23118d2..7a93e72de06f 100644 --- a/NEWS +++ b/NEWS @@ -2,6 +2,10 @@ PHP NEWS ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| ?? ??? ????, PHP 8.4.25 +- Core: + . Fixed bug GH-23088 (Stack overflow when comparing deeply nested arrays). + (Lazizbek Ergashev) + - Date: . Fixed leak on double DatePeriod::__construct() call. (ilutov) diff --git a/Zend/tests/gh18572.phpt b/Zend/tests/gh18572.phpt index ff178ebef24f..cf45d2afaaba 100644 --- a/Zend/tests/gh18572.phpt +++ b/Zend/tests/gh18572.phpt @@ -36,4 +36,4 @@ try { } ?> --EXPECTREGEX-- -(Maximum call stack size reached during object comparison|Nesting level too deep - recursive dependency\?) +(Maximum call stack size reached during (object )?comparison|Nesting level too deep - recursive dependency\?) diff --git a/Zend/tests/gh23088.phpt b/Zend/tests/gh23088.phpt new file mode 100644 index 000000000000..59153a1f2ba3 --- /dev/null +++ b/Zend/tests/gh23088.phpt @@ -0,0 +1,40 @@ +--TEST-- +GH-23088 (Stack overflow when comparing deeply nested arrays) +--SKIPIF-- + +--INI-- +zend.max_allowed_stack_size=256K +--FILE-- +getMessage(), PHP_EOL; +} + +try { + var_dump($a === $b); +} catch (Error $e) { + echo $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +Maximum call stack size reached during comparison +Maximum call stack size reached during comparison diff --git a/Zend/zend_hash.c b/Zend/zend_hash.c index 23637b94bceb..82d0318428fa 100644 --- a/Zend/zend_hash.c +++ b/Zend/zend_hash.c @@ -3214,6 +3214,13 @@ ZEND_API int zend_hash_compare(HashTable *ht1, HashTable *ht2, compare_func_t co return 0; } +#ifdef ZEND_CHECK_STACK_LIMIT + if (UNEXPECTED(zend_call_stack_overflowed(EG(stack_limit)))) { + zend_throw_error(NULL, "Maximum call stack size reached during comparison"); + return ZEND_UNCOMPARABLE; + } +#endif + /* It's enough to protect only one of the arrays. * The second one may be referenced from the first and this may cause * false recursion detection. From a62481f50755bfa09ad7120d196ebcf9659a5ceb Mon Sep 17 00:00:00 2001 From: Shivam Mathur Date: Mon, 10 Aug 2026 14:01:20 +0530 Subject: [PATCH 5/8] Upgrade php-sdk to 2.8.2 (#23193) --- .github/workflows/test-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index e269ed336408..c2b076059bc0 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -852,7 +852,7 @@ jobs: PHP_BUILD_CACHE_BASE_DIR: C:\build-cache PHP_BUILD_OBJ_DIR: C:\obj PHP_BUILD_CACHE_SDK_DIR: C:\build-cache\sdk - PHP_BUILD_SDK_BRANCH: php-sdk-2.7.1 + PHP_BUILD_SDK_BRANCH: php-sdk-2.8.2 PHP_BUILD_CRT: ${{ fromJson(inputs.branch).jobs.WINDOWS.config.vs_crt_version }} PLATFORM: ${{ matrix.x64 && 'x64' || 'x86' }} THREAD_SAFE: "${{ matrix.zts && '1' || '0' }}" From 1d2ea5ce22cd941d1bbf073d08af2b04be99ecf3 Mon Sep 17 00:00:00 2001 From: NickSdot <32384907+NickSdot@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:33:43 +0700 Subject: [PATCH 6/8] Run tests in parallel by default (#22939) Runs the test suite in parallel by default; automatically selects up to 10 workers. Explicit -j1 remains the opt-out to run tests serial. Smaller batches improve balancing. --- .gitignore | 2 +- NEWS | 3 + README.md | 8 +- UPGRADING.INTERNALS | 3 + docs/source/miscellaneous/writing-tests.rst | 5 + ext/gd/tests/createfromwbmp2.phpt | 2 +- ext/gd/tests/createfromwbmp2_extern.phpt | 4 +- ext/zip/tests/oo_addglob_leak.phpt | 4 +- ext/zlib/tests/readgzfile_basic.phpt | 10 +- ext/zlib/tests/readgzfile_basic2.phpt | 10 +- run-tests.php | 72 +++++++++++-- sapi/cli/tests/010-2.phpt | 2 +- sapi/cli/tests/010.phpt | 4 +- tests/run-test/automatic_worker_limit.phpt | 107 ++++++++++++++++++++ 14 files changed, 209 insertions(+), 27 deletions(-) create mode 100644 tests/run-test/automatic_worker_limit.phpt diff --git a/.gitignore b/.gitignore index b76b5a787caa..769ac62cc5d8 100644 --- a/.gitignore +++ b/.gitignore @@ -252,7 +252,7 @@ php # Test results generated by `./run-tests.php` php_test_results_*.txt -# Temporary test information generated by `./run-tests.php` +# Temporary test information generated by `./run-tests.php` (kept for BC; now lives in tmp dir) /run-test-info.php # Temporary POST data placeholder files generated by `./run-tests.php` diff --git a/NEWS b/NEWS index 8ea376a2c247..ae9a19c0cc87 100644 --- a/NEWS +++ b/NEWS @@ -7,6 +7,9 @@ PHP NEWS operation depth. (iliaal) - Core: + . Changed run-tests.php to run in parallel by default, using up to 10 + automatically detected workers. Pass -j1 for sequential execution. + (NickSdot) . Changed run-tests.php to run test subprocesses without a shell where possible. (NickSdot) . Fixed GH-23083 (SEGV build_trace_args in zend_exceptions.c with diff --git a/README.md b/README.md index d83203d74c0b..80618f2d6f94 100644 --- a/README.md +++ b/README.md @@ -97,15 +97,15 @@ can be determined using `nproc`. PHP ships with an extensive test suite, the command `make test` is used after successful compilation of the sources to run this test suite. -It is possible to run tests using multiple cores by setting `-jN` in -`TEST_PHP_ARGS` or `TESTS`: +Tests run in parallel by default, using up to 10 detected logical processors. +Set `-jN` in `TEST_PHP_ARGS` or `TESTS` to override the worker count: ```shell make TEST_PHP_ARGS=-j4 test ``` -Shall run `make test` with a maximum of 4 concurrent jobs: Generally the maximum -number of jobs should not exceed the number of cores available. +This runs `make test` with a maximum of 4 concurrent jobs. Alternatively, +use `-j1` to run tests sequentially. Use the `TEST_PHP_ARGS` or `TESTS` variable to test only specific directories: diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS index 28cefb1e0afb..3c0d3b4f80fa 100644 --- a/UPGRADING.INTERNALS +++ b/UPGRADING.INTERNALS @@ -223,6 +223,9 @@ PHP 8.6 INTERNALS UPGRADE NOTES ======================== - Abstract: + . run-tests.php now runs in parallel by default, using up to 10 automatically + detected workers. Pass -j1 for sequential execution. --asan, --msan, and + Valgrind default to at most two workers. . Minimum required PHP version found on the host system for running scripts like build/gen_stub.php during development has been updated from 7.4 to 8.1. . build/gen_stub.php may now generate a _decl.h file in addition to diff --git a/docs/source/miscellaneous/writing-tests.rst b/docs/source/miscellaneous/writing-tests.rst index 8e17674ae481..4339e042e6a4 100644 --- a/docs/source/miscellaneous/writing-tests.rst +++ b/docs/source/miscellaneous/writing-tests.rst @@ -192,6 +192,11 @@ When you are testing your test case it's really important to make sure that you temporary resources (eg files) that you used in the test. There is a special ``--CLEAN--`` section to help you do this — see `here <#clean>`_. +Tests run in parallel by default. Mutable resources such as files, directories, ports, database +objects, and IPC identifiers must therefore be unique to each test. Read-only fixtures may be +shared. If a resource cannot be isolated, declare the narrowest applicable conflict using +``--CONFLICTS--`` or a ``CONFLICTS`` file. + Another good check is to look at what lines of code in the PHP source your test case covers. This is easy to do, there are some instructions on the `PHP Wiki `_. diff --git a/ext/gd/tests/createfromwbmp2.phpt b/ext/gd/tests/createfromwbmp2.phpt index 4608c861323f..7007ff545d74 100644 --- a/ext/gd/tests/createfromwbmp2.phpt +++ b/ext/gd/tests/createfromwbmp2.phpt @@ -8,7 +8,7 @@ gd ?> --FILE-- "); diff --git a/ext/gd/tests/createfromwbmp2_extern.phpt b/ext/gd/tests/createfromwbmp2_extern.phpt index 68895f9a3570..711f2e8ca3ed 100644 --- a/ext/gd/tests/createfromwbmp2_extern.phpt +++ b/ext/gd/tests/createfromwbmp2_extern.phpt @@ -4,7 +4,7 @@ imagecreatefromwbmp with invalid wbmp gd --FILE-- "); @@ -41,4 +41,4 @@ unlink($filename); --EXPECTF-- Warning: imagecreatefromwbmp(): %croduct of memory allocation multiplication would exceed INT_MAX, failing operation gracefully%win %s on line %d -Warning: imagecreatefromwbmp(): "%s_tmp.wbmp" is not a valid WBMP file in %s on line %d +Warning: imagecreatefromwbmp(): "%s_tmp_createfromwbmp2_extern.wbmp" is not a valid WBMP file in %s on line %d diff --git a/ext/zip/tests/oo_addglob_leak.phpt b/ext/zip/tests/oo_addglob_leak.phpt index 9040c5565f84..be7f92dccb90 100644 --- a/ext/zip/tests/oo_addglob_leak.phpt +++ b/ext/zip/tests/oo_addglob_leak.phpt @@ -12,7 +12,7 @@ if(!defined("GLOB_BRACE")) die ('skip requires GLOB_BRACE'); $dirname = __DIR__ . '/'; include $dirname . 'utils.inc'; -$dirname = __DIR__ . '/__tmp_oo_addglob2/'; +$dirname = __DIR__ . '/__tmp_oo_addglob_leak/'; $file = $dirname . 'test.zip'; @mkdir($dirname); @@ -38,7 +38,7 @@ var_dump($zip->addGlob($dirname . 'bar.*', GLOB_BRACE, $options)); --EXPECTF-- array(1) { diff --git a/ext/zlib/tests/readgzfile_basic.phpt b/ext/zlib/tests/readgzfile_basic.phpt index ae829406e0d4..cf579879e2fc 100644 --- a/ext/zlib/tests/readgzfile_basic.phpt +++ b/ext/zlib/tests/readgzfile_basic.phpt @@ -10,7 +10,7 @@ is a very common test for all languages EOT; -$dirname = 'readgzfile_temp'; +$dirname = 'readgzfile_basic_temp'; $filename = $dirname.'/readgzfile_basic.txt.gz'; mkdir($dirname); $h = gzopen($filename, 'w'); @@ -19,9 +19,11 @@ gzclose($h); var_dump(readgzfile( $filename ) ); - -unlink($filename); -rmdir($dirname); +?> +--CLEAN-- + --EXPECT-- hello world diff --git a/ext/zlib/tests/readgzfile_basic2.phpt b/ext/zlib/tests/readgzfile_basic2.phpt index ff19bcd35e93..6edb4c85784a 100644 --- a/ext/zlib/tests/readgzfile_basic2.phpt +++ b/ext/zlib/tests/readgzfile_basic2.phpt @@ -10,7 +10,7 @@ is a very common test for all languages EOT; -$dirname = 'readgzfile_temp'; +$dirname = 'readgzfile_basic2_temp'; $filename = $dirname.'/readgzfile_basic2.txt'; mkdir($dirname); $h = fopen($filename, 'w'); @@ -19,9 +19,11 @@ fclose($h); var_dump(readgzfile( $filename ) ); - -unlink($filename); -rmdir($dirname); +?> +--CLEAN-- + --EXPECT-- hello world diff --git a/run-tests.php b/run-tests.php index 6eed6649aec7..ce6bd94db1e0 100755 --- a/run-tests.php +++ b/run-tests.php @@ -34,9 +34,9 @@ function show_usage(): void php run-tests.php [options] [files] [directories] Options: - -j Run up to simultaneous testing processes in parallel for - quicker testing on systems with multiple logical processors. - Note that this is experimental feature. + -j Run up to simultaneous testing processes. By default, + the worker count is detected automatically. Use -j1 to run + tests sequentially. -l Read the testfiles to be executed from . After the test has finished all failed tests are written to the same . @@ -356,6 +356,7 @@ function main(): void $shuffle = false; $bless = false; $workers = null; + $workersExplicit = false; $context_line_count = 3; $num_repeats = 1; $show_progress = true; @@ -417,6 +418,7 @@ function main(): void switch ($switch) { case 'j': + $workersExplicit = true; $workers = substr($argv[$i], 2); if ($workers == 0 || !preg_match('/^\d+$/', $workers)) { error("'$workers' is not a valid number of workers, try e.g. -j16 for 16 workers"); @@ -646,6 +648,17 @@ function main(): void } } + if (!$workersExplicit && (!$selected_tests || count($test_files) > 1)) { + $workers = get_default_worker_count(); + if ($workers !== null + && ($valgrind !== null || isset($environment['SKIP_ASAN']))) { + $workers = min($workers, 2); + } + if ($workers !== null && !can_create_parallel_worker_socket()) { + $workers = null; + } + } + if ($online === null && !isset($environment['SKIP_ONLINE_TESTS'])) { $online = false; } @@ -808,6 +821,53 @@ function main(): void } } +function get_default_worker_count(): ?int +{ + if (IS_WINDOWS) { + $workerCount = getenv('NUMBER_OF_PROCESSORS'); + return is_string($workerCount) ? parse_default_worker_count($workerCount) : null; + } + + $commands = [ + 'nproc 2>/dev/null', + 'getconf _NPROCESSORS_ONLN 2>/dev/null', + 'getconf NPROCESSORS_ONLN 2>/dev/null', + 'sysctl -n hw.logicalcpu 2>/dev/null', + 'sysctl -n hw.ncpu 2>/dev/null', + ]; + foreach ($commands as $command) { + $workerCount = shell_exec($command); + if (is_string($workerCount) + && ($workerCount = parse_default_worker_count($workerCount)) !== null) { + return $workerCount; + } + } + + return null; +} + +function parse_default_worker_count(string $workerCount): ?int +{ + $workerCount = trim($workerCount); + if (preg_match('/^[0-9]+$/D', $workerCount) !== 1) { + return null; + } + + $workerCount = (int) $workerCount; + return $workerCount >= 2 ? min($workerCount, 10) : null; +} + +function can_create_parallel_worker_socket(): bool +{ + $socket = @stream_socket_server('tcp://127.0.0.1:0'); + if ($socket === false) { + return false; + } + + fclose($socket); + return true; +} + function verify_config(string $php): void { if (empty($php) || !file_exists($php)) { @@ -830,7 +890,7 @@ function write_information(array $user_tests, $phpdbg): void $escaped_no_file_cache = escaped_shell_string_from($no_file_cache); // Get info from php - $info_file = __DIR__ . '/run-test-info.php'; + $info_file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'run-test-info-' . getmypid() . '.php'; @unlink($info_file); $php_info = ' --EXPECT-- string(25) " diff --git a/tests/run-test/automatic_worker_limit.phpt b/tests/run-test/automatic_worker_limit.phpt new file mode 100644 index 000000000000..5bf6df07365e --- /dev/null +++ b/tests/run-test/automatic_worker_limit.phpt @@ -0,0 +1,107 @@ +--TEST-- +Automatic worker detection is capped for regular and instrumented runs +--SKIPIF-- + +--ENV-- +TEST_PHP_FORK_SERVER=0 +--FILE-- + + --EXPECT-- + ok + PHPT); +} + +$environment = [ + 'PATH' => $bin . PATH_SEPARATOR . getenv('PATH'), + 'TEST_PHP_EXECUTABLE' => getenv('TEST_PHP_EXECUTABLE'), + 'TEST_PHP_FORK_SERVER' => '0', +]; +foreach (['TEMP', 'TMPDIR'] as $name) { + if (($value = getenv($name)) !== false) { + $environment[$name] = $value; + } +} + +$runTests = static function (array $arguments) use ($environment, $testFiles): array { + $process = proc_open( + [ + getenv('TEST_PHP_EXECUTABLE'), + dirname(__DIR__, 2) . '/run-tests.php', + '-q', + '--no-progress', + ...$arguments, + ...$testFiles, + ], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['redirect', 1], + ], + $pipes, + null, + $environment, + ); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + return [proc_close($process), $output]; +}; + +[$exitCode, $output] = $runTests([]); +var_dump($exitCode); +var_dump(str_contains($output, 'Spawning 10 workers...')); +var_dump(str_contains($output, 'Spawning 11 workers...')); + +[$exitCode, $output] = $runTests(['--asan']); +var_dump($exitCode); +var_dump(str_contains($output, 'Spawning 2 workers...')); +var_dump(str_contains($output, 'Spawning 10 workers...')); + +[$exitCode, $output] = $runTests(['--asan', '-j3']); +var_dump($exitCode); +var_dump(str_contains($output, 'Spawning 3 workers...')); +?> +--CLEAN-- + +--EXPECT-- +int(0) +bool(true) +bool(false) +int(0) +bool(true) +bool(false) +int(0) +bool(true) From 69d5f1b5d20b160cdd80ea65ef7d8e7548fd9935 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Mon, 10 Aug 2026 14:57:00 +0500 Subject: [PATCH 7/8] Add a stack limit check in php_array_walk() (#23125) php_array_walk() recurses once per nesting level with no stack check, so array_walk_recursive() on a deeply nested array exhausts the native stack and the process dies with a segfault. This adds the same stack limit check ext/standard already uses in var.c and http.c, so the call throws an Error instead of crashing. The existing GC_IS_RECURSIVE guard only covers self-referential arrays, not plain deep nesting. Fixes GH-23111 --- NEWS | 4 ++++ ext/standard/array.c | 7 +++++++ ext/standard/tests/array/gh23111.phpt | 27 +++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 ext/standard/tests/array/gh23111.phpt diff --git a/NEWS b/NEWS index 7a93e72de06f..01eb4160a20a 100644 --- a/NEWS +++ b/NEWS @@ -69,6 +69,10 @@ PHP NEWS - SQLite: . Fix leak when trying to close db if blob stream is still open. (ndossche) +- Standard: + . Fixed bug GH-23111 (Stack overflow in array_walk_recursive() with deeply + nested arrays). (Lazizbek Ergashev) + - Streams: . Fixed bug GH-15836 (Use-after-free when a user stream filter accesses $this->stream during the close flush). (iliaal) diff --git a/ext/standard/array.c b/ext/standard/array.c index 4527d9a80df8..6863586c81ff 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -1465,6 +1465,13 @@ static zend_result php_array_walk( * levels of recursion. */ zend_fcall_info fci = context->fci; +#ifdef ZEND_CHECK_STACK_LIMIT + if (UNEXPECTED(zend_call_stack_overflowed(EG(stack_limit)))) { + zend_call_stack_size_error(); + return FAILURE; + } +#endif + if (zend_hash_num_elements(target_hash) == 0) { return result; } diff --git a/ext/standard/tests/array/gh23111.phpt b/ext/standard/tests/array/gh23111.phpt new file mode 100644 index 000000000000..21db33a2fc75 --- /dev/null +++ b/ext/standard/tests/array/gh23111.phpt @@ -0,0 +1,27 @@ +--TEST-- +GH-23111 (Stack overflow in array_walk_recursive with deeply nested arrays) +--SKIPIF-- + +--INI-- +zend.max_allowed_stack_size=256K +--FILE-- +getMessage(), "\n"; +} +?> +--EXPECTF-- +Error: Maximum call stack size of %d bytes (zend.max_allowed_stack_size - zend.reserved_stack_size) reached. Infinite recursion? From af1abdfc34b0f4fb0ad9cfef5533a989bc4a02ab Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Mon, 10 Aug 2026 14:59:11 +0500 Subject: [PATCH 8/8] Add a stack limit check in php_array_replace_recursive (#23124) php_array_replace_recursive() recurses once per nesting level with no stack check, so array_replace_recursive() on a deeply nested array exhausts the native stack and the process dies with a segfault. Fixes GH-23113 --- NEWS | 2 ++ ext/standard/array.c | 7 +++++++ ext/standard/tests/array/gh23113.phpt | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 ext/standard/tests/array/gh23113.phpt diff --git a/NEWS b/NEWS index 01eb4160a20a..d7dadb551bcb 100644 --- a/NEWS +++ b/NEWS @@ -72,6 +72,8 @@ PHP NEWS - Standard: . Fixed bug GH-23111 (Stack overflow in array_walk_recursive() with deeply nested arrays). (Lazizbek Ergashev) + . Fixed bug GH-23113 (Stack overflow in array_replace_recursive() with deeply + nested arrays). (Lazizbek Ergashev) - Streams: . Fixed bug GH-15836 (Use-after-free when a user stream filter accesses diff --git a/ext/standard/array.c b/ext/standard/array.c index 6863586c81ff..41123d43bcc6 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -4168,6 +4168,13 @@ PHPAPI int php_array_replace_recursive(HashTable *dest, HashTable *src) /* {{{ * zend_ulong num_key; int ret; +#ifdef ZEND_CHECK_STACK_LIMIT + if (UNEXPECTED(zend_call_stack_overflowed(EG(stack_limit)))) { + zend_call_stack_size_error(); + return 0; + } +#endif + ZEND_HASH_FOREACH_KEY_VAL(src, num_key, string_key, src_entry) { src_zval = src_entry; ZVAL_DEREF(src_zval); diff --git a/ext/standard/tests/array/gh23113.phpt b/ext/standard/tests/array/gh23113.phpt new file mode 100644 index 000000000000..894e8d971e53 --- /dev/null +++ b/ext/standard/tests/array/gh23113.phpt @@ -0,0 +1,27 @@ +--TEST-- +GH-23113 (Stack overflow in array_replace_recursive with deeply nested arrays) +--SKIPIF-- + +--INI-- +zend.max_allowed_stack_size=256K +--FILE-- + $a]; +} +try { + array_replace_recursive($a, $a); +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), "\n"; +} +?> +--EXPECTF-- +Error: Maximum call stack size of %d bytes (zend.max_allowed_stack_size - zend.reserved_stack_size) reached. Infinite recursion?