From 8e394ebf134363e57856e106fd81ce8ceb69d3b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Bu=CC=88nemann?= Date: Thu, 12 Nov 2026 12:07:00 +0100 Subject: [PATCH] Make setlocale() thread-local setlocale() changes the locale of the whole process and isn't thread-safe. In thread-safe builds, a request used the locale set by requests running in other threads, the reset at the end of a request also reset the locale of all other requests, and concurrent calls could crash. zend_setlocale() replaces it. During a request, it switches the current thread to a locale of its own with uselocale(), which the locale dependent functions of the C library use in place of the process locale. Outside of a request, it still changes the process locale, which is the initial locale of all threads. Builds that aren't thread-safe use the same code, so there is a single implementation. Windows already keeps the locale per thread, and systems without uselocale() keep using setlocale(). The locale objects are created once for each combination of locales and shared by all threads. They are never freed: threads keep using them, and macOS crashes when locales are created and freed concurrently. Known locales are looked up without a lock, only creating one takes it. The ENV sections of setlocale_variation4.phpt and setlocale_variation5.phpt had a trailing semicolon, which made the locale "en_US.utf8;". glibc accepts that name in setlocale(), but not in newlocale(). --- NEWS | 2 + UPGRADING | 4 + UPGRADING.INTERNALS | 5 + Zend/zend_locale.c | 357 ++++++++++++++++++ Zend/zend_locale.h | 42 +++ Zend/zend_operators.c | 7 +- configure.ac | 3 + ext/intl/formatter/formatter_parse.cpp | 7 +- ext/standard/basic_functions.c | 3 +- ext/standard/string.c | 7 +- .../tests/strings/setlocale_variation4.phpt | 2 +- .../tests/strings/setlocale_variation5.phpt | 2 +- .../tests/strings/setlocale_variation6.phpt | 36 ++ 13 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 Zend/zend_locale.c create mode 100644 Zend/zend_locale.h create mode 100644 ext/standard/tests/strings/setlocale_variation6.phpt diff --git a/NEWS b/NEWS index 4938b79b0713..d60f4f4f13d7 100644 --- a/NEWS +++ b/NEWS @@ -205,6 +205,8 @@ PHP NEWS specified number base. (Sjoerd Langkemper) . Improved performance of in_array() and array_search() in strict mode when searching for a string. (Alexander Danilov) + . setlocale() now only changes the locale of the current thread. + (Felix Bünemann) - URI: . Fix casing of enum cases in UriHostType and UrlHostType to match the RFC diff --git a/UPGRADING b/UPGRADING index d9242ecb3c17..d0f53db2333d 100644 --- a/UPGRADING +++ b/UPGRADING @@ -120,6 +120,10 @@ PHP 8.7 UPGRADE NOTES rebuilding PHP with --enable-fd-setsize. On Windows, selecting on more than 64 pipe or file handles no longer crashes; it fails with a warning, as that path remains bounded by WaitForMultipleObjects(). + . setlocale() now only changes the locale of the current thread, as it + already did in thread-safe (ZTS) builds on Windows. Previously it changed + the locale of the whole process: in ZTS builds, requests running in other + threads used that locale too, and concurrent calls could crash. ======================================== 6. New Functions diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS index b5134705b375..a37ce029ce41 100644 --- a/UPGRADING.INTERNALS +++ b/UPGRADING.INTERNALS @@ -60,6 +60,11 @@ PHP 8.7 INTERNALS UPGRADE NOTES - The ZTS symbols compiler_globals_offset and executor_globals_offset have been removed: CG()/EG() now reach the globals through a __thread struct. - Removed zend_error_noreturn_unchecked(). +- Added zend_setlocale(). During a request it only changes the locale of the + current thread, which is what the setlocale() function of PHP now does. + Extensions should use it instead of the setlocale() function of the C + library, which still changes the locale of the whole process and doesn't + return the locale of the current thread. ======================== 2. Build system changes diff --git a/Zend/zend_locale.c b/Zend/zend_locale.c new file mode 100644 index 000000000000..f0752e44fe61 --- /dev/null +++ b/Zend/zend_locale.c @@ -0,0 +1,357 @@ +/* + +----------------------------------------------------------------------+ + | Zend Engine | + +----------------------------------------------------------------------+ + | Copyright © Zend Technologies Ltd., a subsidiary company of | + | Perforce Software, Inc., and Contributors. | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Authors: Felix Bünemann | + +----------------------------------------------------------------------+ +*/ + +#include "zend_locale.h" + +#ifdef ZEND_THREAD_LOCALE + +#include "zend.h" +#include "zend_globals.h" +#include "zend_smart_str.h" + +#ifdef HAVE_XLOCALE_H +# include +#endif + +/* setlocale() changes the locale of the whole process and isn't thread-safe. + * zend_setlocale() switches the current thread to a locale of its own with + * uselocale() instead. The locale dependent functions of the C library then + * use that locale in place of the process locale. Builds that aren't + * thread-safe work the same way, with a single thread. */ + +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__DragonFly__) +# define ZEND_LOCALE_BSD 1 +#elif defined(__MUSL__) +# define ZEND_LOCALE_MUSL 1 +#endif + +/* Indexed by category */ +static const struct { + int mask; + const char *name; +} categories[] = { + [LC_ALL] = {LC_ALL_MASK, "LC_ALL"}, + [LC_COLLATE] = {LC_COLLATE_MASK, "LC_COLLATE"}, + [LC_CTYPE] = {LC_CTYPE_MASK, "LC_CTYPE"}, + [LC_MONETARY] = {LC_MONETARY_MASK, "LC_MONETARY"}, + [LC_NUMERIC] = {LC_NUMERIC_MASK, "LC_NUMERIC"}, + [LC_TIME] = {LC_TIME_MASK, "LC_TIME"}, + [LC_MESSAGES] = {LC_MESSAGES_MASK, "LC_MESSAGES"}, +#ifdef LC_PAPER_MASK + [LC_PAPER] = {LC_PAPER_MASK, "LC_PAPER"}, +#endif +#ifdef LC_NAME_MASK + [LC_NAME] = {LC_NAME_MASK, "LC_NAME"}, +#endif +#ifdef LC_ADDRESS_MASK + [LC_ADDRESS] = {LC_ADDRESS_MASK, "LC_ADDRESS"}, +#endif +#ifdef LC_TELEPHONE_MASK + [LC_TELEPHONE] = {LC_TELEPHONE_MASK, "LC_TELEPHONE"}, +#endif +#ifdef LC_MEASUREMENT_MASK + [LC_MEASUREMENT] = {LC_MEASUREMENT_MASK, "LC_MEASUREMENT"}, +#endif +#ifdef LC_IDENTIFICATION_MASK + [LC_IDENTIFICATION] = {LC_IDENTIFICATION_MASK, "LC_IDENTIFICATION"}, +#endif +}; + +#define CATEGORY_COUNT ((int) (sizeof(categories) / sizeof(categories[0]))) + +/* All categories except LC_ALL, in the order of their constants. The C library + * uses the same order for the names in the name of LC_ALL. */ +#define FOREACH_CATEGORY(category) \ + for (int category = 0; category < CATEGORY_COUNT; category++) \ + if (category != LC_ALL && categories[category].name) + +/* When the categories have different locales, the name of LC_ALL is made of + * their names, as in the C library: "C/C.UTF-8/C/C/C/C" on macOS and BSD, + * "LC_CTYPE=C.UTF-8;LC_NUMERIC=C;..." on glibc and "C.UTF-8;C;C;C;C;C" + * elsewhere (musl). */ +#ifdef ZEND_LOCALE_BSD +# define NAME_SEPARATOR "/" +#else +# define NAME_SEPARATOR ";" +#endif + +typedef struct _zend_locale { + locale_t locale; + struct _zend_locale *next; + /* Indexed by category */ + char *names[CATEGORY_COUNT]; +} zend_locale; + +/* Shared by all threads and never freed: threads keep using them, and a + * process only needs one for each combination of locales. A locale doesn't + * change once it is in the list and new ones become the head, so the list can + * be read without a lock. */ +static _Atomic(zend_locale *) locales = NULL; + +/* Locale of the current thread, NULL for the process locale */ +ZEND_TLS zend_locale *thread_locale = NULL; + +/* The name setlocale() returns for a locale. The name "" is the locale of the + * environment and needs the environment lock. */ +static const char *zend_locale_resolve_name(int category, const char *name) +{ + if (name[0] == '\0') { + /* The locale of the environment */ + const char *variables[] = {"LC_ALL", categories[category].name, "LANG"}; + +#ifdef ZEND_LOCALE_MUSL + name = "C.UTF-8"; +#else + name = "C"; +#endif + for (size_t i = 0; i < sizeof(variables) / sizeof(variables[0]); i++) { + const char *value = getenv(variables[i]); + if (value && value[0] != '\0') { + name = value; + break; + } + } + } + +#ifndef ZEND_LOCALE_BSD + if (strcmp(name, "POSIX") == 0) { + return "C"; + } +#endif +#ifdef ZEND_LOCALE_MUSL + /* musl only has a C.UTF-8 locale for LC_CTYPE */ + if (category != LC_CTYPE && strcmp(name, "C.UTF-8") == 0) { + return "C"; + } +#endif + + return name; +} + +/* Splits a name of LC_ALL into the names of the categories */ +static bool zend_locale_split_name(char *all, const char **names) +{ +#ifdef __GLIBC__ + int count = 0; + char *last = NULL; + + for (char *name = strtok_r(all, NAME_SEPARATOR, &last); name; name = strtok_r(NULL, NAME_SEPARATOR, &last)) { + char *value = strchr(name, '='); + if (!value) { + return false; + } + *value = '\0'; + + int category = 0; + while (category < CATEGORY_COUNT && (category == LC_ALL || strcmp(categories[category].name, name) != 0)) { + category++; + } + if (category == CATEGORY_COUNT) { + return false; + } + names[category] = value + 1; + count++; + } + + return count == CATEGORY_COUNT - 1; +#else + FOREACH_CATEGORY(category) { + char *end = strstr(all, NAME_SEPARATOR); + if (end) { + *end = '\0'; + } +#ifdef ZEND_LOCALE_BSD + /* Elsewhere, an empty name is the locale of the environment */ + if (all[0] == '\0') { + return false; + } +#endif + names[category] = all; + /* The last name applies to the remaining categories */ + if (end) { + all = end + 1; + } + } + + return true; +#endif +} + +/* The name of LC_ALL for the names of the categories */ +static char *zend_locale_join_names(const char **names) +{ + smart_str all = {0}; + bool equal = true; + + FOREACH_CATEGORY(category) { + if (all.s) { + smart_str_appends_ex(&all, NAME_SEPARATOR, true); + } +#ifdef __GLIBC__ + smart_str_appends_ex(&all, categories[category].name, true); + smart_str_appendc_ex(&all, '=', true); +#endif + smart_str_appends_ex(&all, names[category], true); + equal = equal && strcmp(names[category], names[LC_CTYPE]) == 0; + } + smart_str_0(&all); + + char *name = pestrdup(equal ? names[LC_CTYPE] : ZSTR_VAL(all.s), 1); + smart_str_free_ex(&all, true); + + return name; +} + +static zend_locale *zend_locale_find(const char **names) +{ + for (zend_locale *locale = atomic_load_explicit(&locales, memory_order_acquire); locale; locale = locale->next) { + bool equal = true; + + FOREACH_CATEGORY(category) { + equal = equal && strcmp(locale->names[category], names[category]) == 0; + } + if (equal) { + return locale; + } + } + + return NULL; +} + +/* Returns the shared locale with these names, NULL if it isn't available */ +static zend_locale *zend_locale_get(const char **names) +{ + bool environment = false; + + FOREACH_CATEGORY(category) { + environment = environment || names[category][0] == '\0'; + } + if (!environment) { + FOREACH_CATEGORY(category) { + names[category] = zend_locale_resolve_name(category, names[category]); + } + + zend_locale *found = zend_locale_find(names); + if (found) { + return found; + } + } + + /* Locales are created one at a time: concurrent newlocale() calls crash + * on macOS, and the "" locale reads the environment */ + tsrm_env_lock(); + + FOREACH_CATEGORY(category) { + names[category] = zend_locale_resolve_name(category, names[category]); + } + + zend_locale *found = zend_locale_find(names); + if (!found) { + locale_t locale = newlocale(LC_ALL_MASK, "C", (locale_t) 0); + + FOREACH_CATEGORY(category) { + if (locale) { + locale_t base = locale; + locale = newlocale(categories[category].mask, names[category], base); + /* On success, newlocale() takes over the base locale, except + * on macOS */ +#ifdef __APPLE__ + freelocale(base); +#else + if (!locale) { + freelocale(base); + } +#endif + } + } + + if (locale) { + found = pemalloc(sizeof(zend_locale), 1); + found->locale = locale; + FOREACH_CATEGORY(category) { + found->names[category] = pestrdup(names[category], 1); + } + found->names[LC_ALL] = zend_locale_join_names(names); + found->next = atomic_load_explicit(&locales, memory_order_relaxed); + atomic_store_explicit(&locales, found, memory_order_release); + } + } + + tsrm_env_unlock(); + + return found; +} + +ZEND_API const char *zend_setlocale(int category, const char *locale) +{ + /* Outside of a request, the process locale is changed: it's set during + * startup and is the initial locale of all threads */ + if (!EG(active)) { + if (locale && thread_locale) { + uselocale(LC_GLOBAL_LOCALE); + thread_locale = NULL; + } + return setlocale(category, locale); + } + + if (category < 0 || category >= CATEGORY_COUNT || !categories[category].name) { + return NULL; + } + + const char *names[CATEGORY_COUNT]; + zend_locale *current = thread_locale; + + if (!current) { + /* The thread still uses the process locale. Its name of LC_ALL comes + * from a shared locale, as querying it isn't thread-safe. */ + FOREACH_CATEGORY(i) { + names[i] = setlocale(i, NULL); + } + current = zend_locale_get(names); + } + if (!locale || !current) { + return current ? current->names[category] : NULL; + } + + char *all = NULL; + + FOREACH_CATEGORY(i) { + names[i] = (category == LC_ALL || category == i) ? locale : current->names[i]; + } + if (category == LC_ALL && strstr(locale, NAME_SEPARATOR)) { + all = estrdup(locale); + if (!zend_locale_split_name(all, names)) { + efree(all); + return NULL; + } + } + + zend_locale *found = zend_locale_get(names); + if (all) { + efree(all); + } + if (!found) { + return NULL; + } + + uselocale(found->locale); + thread_locale = found; + + return found->names[category]; +} + +#endif diff --git a/Zend/zend_locale.h b/Zend/zend_locale.h new file mode 100644 index 000000000000..ec38ef40aa25 --- /dev/null +++ b/Zend/zend_locale.h @@ -0,0 +1,42 @@ +/* + +----------------------------------------------------------------------+ + | Zend Engine | + +----------------------------------------------------------------------+ + | Copyright © Zend Technologies Ltd., a subsidiary company of | + | Perforce Software, Inc., and Contributors. | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Authors: Felix Bünemann | + +----------------------------------------------------------------------+ +*/ + +#ifndef ZEND_LOCALE_H +#define ZEND_LOCALE_H + +#include "zend_portability.h" + +#include + +BEGIN_EXTERN_C() + +/* zend_setlocale() keeps a table indexed by category, which needs LC_ALL not + * to be negative, as it is on AIX */ +#if defined(HAVE_USELOCALE) && LC_ALL >= 0 +# define ZEND_THREAD_LOCALE 1 +/* Like setlocale(), but during a request it only changes the locale of the + * current thread */ +ZEND_API const char *zend_setlocale(int category, const char *locale); +#else +/* On Windows the locale is already per thread in thread-safe builds, see + * _configthreadlocale() */ +# define zend_setlocale(category, locale) setlocale(category, locale) +#endif + +END_EXTERN_C() + +#endif /* ZEND_LOCALE_H */ diff --git a/Zend/zend_operators.c b/Zend/zend_operators.c index 2960b4be1400..ebde442a5a2c 100644 --- a/Zend/zend_operators.c +++ b/Zend/zend_operators.c @@ -24,6 +24,7 @@ #include "zend_variables.h" #include "zend_globals.h" #include "zend_list.h" +#include "zend_locale.h" #include "zend_API.h" #include "zend_strtod.h" #include "zend_exceptions.h" @@ -3016,7 +3017,7 @@ ZEND_API void zend_update_current_locale(void) /* {{{ */ #else char buf[16]; const char *charmap = NULL; - const char *locale = setlocale(LC_CTYPE, NULL); + const char *locale = zend_setlocale(LC_CTYPE, NULL); if (locale) { const char *dot = strchr(locale, '.'); @@ -3072,8 +3073,8 @@ ZEND_API void zend_reset_lc_ctype_locale(void) { /* Use the C.UTF-8 locale so that readline can process UTF-8 input, while not interfering * with single-byte locale-dependent functions used by PHP. */ - if (!setlocale(LC_CTYPE, "C.UTF-8")) { - setlocale(LC_CTYPE, "C"); + if (!zend_setlocale(LC_CTYPE, "C.UTF-8")) { + zend_setlocale(LC_CTYPE, "C"); } } diff --git a/configure.ac b/configure.ac index abfddd1f28e4..f313bcc93893 100644 --- a/configure.ac +++ b/configure.ac @@ -416,6 +416,7 @@ AC_CHECK_HEADERS([ unistd.h unix.h utime.h + xlocale.h sys/utsname.h sys/ipc.h dlfcn.h @@ -586,6 +587,7 @@ AC_CHECK_FUNCS([ symlink tzset unsetenv + uselocale usleep utime vasprintf @@ -1801,6 +1803,7 @@ PHP_ADD_SOURCES([Zend], m4_normalize([ zend_lazy_objects.c zend_list.c zend_llist.c + zend_locale.c zend_max_execution_timer.c zend_multibyte.c zend_object_handlers.c diff --git a/ext/intl/formatter/formatter_parse.cpp b/ext/intl/formatter/formatter_parse.cpp index 0acab835085c..ae7135bdb0f5 100644 --- a/ext/intl/formatter/formatter_parse.cpp +++ b/ext/intl/formatter/formatter_parse.cpp @@ -25,6 +25,7 @@ #include "php_intl.h" #include "intl_convert.h" +#include "zend_locale.h" #include #include @@ -71,8 +72,8 @@ U_CFUNC PHP_FUNCTION( numfmt_parse ) #if ICU_LOCALE_BUG && defined(LC_NUMERIC) /* need to copy here since setlocale may change it later */ - oldlocale = estrdup(setlocale(LC_NUMERIC, NULL)); - setlocale(LC_NUMERIC, "C"); + oldlocale = estrdup(zend_setlocale(LC_NUMERIC, NULL)); + zend_setlocale(LC_NUMERIC, "C"); #endif switch(type) { @@ -132,7 +133,7 @@ U_CFUNC PHP_FUNCTION( numfmt_parse ) cleanup: #if ICU_LOCALE_BUG && defined(LC_NUMERIC) - setlocale(LC_NUMERIC, oldlocale); + zend_setlocale(LC_NUMERIC, oldlocale); efree(oldlocale); #endif diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c index db302ab724ab..166b793fe8a6 100644 --- a/ext/standard/basic_functions.c +++ b/ext/standard/basic_functions.c @@ -33,6 +33,7 @@ #include "zend_exceptions.h" #include "zend_ini.h" #include "zend_hrtime.h" +#include "zend_locale.h" #include "zend_operators.h" #include "ext/standard/php_dns.h" #include "ext/standard/php_uuencode.h" @@ -448,7 +449,7 @@ PHP_RSHUTDOWN_FUNCTION(basic) /* {{{ */ /* Check if locale was changed and change it back * to the value in startup environment */ if (BG(locale_changed)) { - setlocale(LC_ALL, "C"); + zend_setlocale(LC_ALL, "C"); zend_reset_lc_ctype_locale(); zend_update_current_locale(); if (BG(ctype_string)) { diff --git a/ext/standard/string.c b/ext/standard/string.c index 40057af16a75..b1757acd16f4 100644 --- a/ext/standard/string.c +++ b/ext/standard/string.c @@ -29,6 +29,7 @@ #include "scanf.h" #include "zend_API.h" #include "zend_execute.h" +#include "zend_locale.h" #include "basic_functions.h" #include "zend_smart_str.h" #include @@ -5093,7 +5094,7 @@ static zend_string *try_setlocale_str(zend_long cat, zend_string *loc) { } # ifndef PHP_WIN32 - retval = setlocale(cat, loc ? ZSTR_VAL(loc) : NULL); + retval = zend_setlocale(cat, loc ? ZSTR_VAL(loc) : NULL); # else if (loc) { /* BC: don't try /^[a-z]{2}_[A-Z]{2}($|\..*)/ except for /^u[ks]_U[KS]$/ */ @@ -5108,10 +5109,10 @@ static zend_string *try_setlocale_str(zend_long cat, zend_string *loc) { ) { retval = NULL; } else { - retval = setlocale(cat, ZSTR_VAL(loc)); + retval = zend_setlocale(cat, ZSTR_VAL(loc)); } } else { - retval = setlocale(cat, NULL); + retval = zend_setlocale(cat, NULL); } # endif if (!retval) { diff --git a/ext/standard/tests/strings/setlocale_variation4.phpt b/ext/standard/tests/strings/setlocale_variation4.phpt index a2b236c146c8..843c67e2e3b4 100644 --- a/ext/standard/tests/strings/setlocale_variation4.phpt +++ b/ext/standard/tests/strings/setlocale_variation4.phpt @@ -11,7 +11,7 @@ if (setlocale(LC_ALL,'en_US.utf8') === false || setlocale(LC_ALL,'en_AU.utf8') = } ?> --ENV-- -LC_ALL=en_US.utf8; +LC_ALL=en_US.utf8 --FILE-- --ENV-- -LC_ALL=en_US.utf8; +LC_ALL=en_US.utf8 --FILE-- +--FILE-- + +--EXPECT-- +bool(true) +string(1) "C" +bool(true) +bool(true) +string(1) "C" +bool(true)